Convertidores de ruta Django 2.0+

¡Hola!



El enrutamiento en Django desde la segunda versión del marco recibió una herramienta maravillosa: los convertidores. Con la incorporación de esta herramienta, fue posible no solo configurar de manera flexible los parámetros en las rutas, sino también separar las áreas de responsabilidad de los componentes.



Mi nombre es Alexander Ivanov, soy mentor en Yandex.Practicum en la facultad de desarrollo backend y desarrollador líder en el Laboratorio de Modelado por Computadora. En este artículo, lo guiaré a través de los convertidores de ruta de Django y le mostraré los beneficios de usarlos. Lo primero para empezar son los límites de aplicabilidad:











  1. Django versión 2.0+;
  2. El registro de rutas debe hacerse con django.urls.path



    .


Entonces, cuando una solicitud llega al servidor Django, primero pasa por la cadena de middleware y luego se activa URLResolver ( algoritmo ). La tarea de este último es encontrar una adecuada en la lista de rutas registradas.



Para un análisis de fondo, propongo considerar la siguiente situación: existen varios puntos finales que deberían generar diferentes informes para una fecha determinada. Supongamos que los puntos finales se ven así:



users/21/reports/2021-01-31/
teams/4/reports/2021-01-31/
      
      







¿Cuáles serían las rutas en urls.py



? Por ejemplo, así:



path('users/<id>/reports/<date>/', user_report, name='user_report'),
path('teams/<id>/reports/<date>/', team_report, name='team_report'),
      
      





Cada elemento de < >



es un parámetro de solicitud y se pasará al controlador.

Importante: el nombre del parámetro al registrar la ruta y el nombre del parámetro en el controlador deben coincidir.


Entonces, cada controlador tendría algo como esto (preste atención a las anotaciones de tipo):



def user_report(request, id: str, date: str):
   try:
       id = int(id)
       date = datetime.strptime(date, '%Y-%m-%d')
   except ValueError:
       raise Http404()
  
   # ...
      
      





Pero esto no es un asunto de la realeza: copiar y pegar ese bloque de código para cada controlador. Es razonable mover este código a una función auxiliar:



def validate_params(id: str, date: str) -> (int, datetime):
   try:
       id = int(id)
       date = datetime.strptime(date, '%Y-%m-%d')
   except ValueError:
       raise Http404('Not found')
   return id, date
      
      





Y en cada controlador habrá una simple llamada a esta función auxiliar:



def user_report(request, id: str, date: str):
   id, date = validate_params(id, date)
  
   # ...
      
      





En general, esto ya es digerible. La función auxiliar devolverá los parámetros correctos de los tipos requeridos o abortará el manejador. Todo parece estar bien,



Pero, de hecho, esto es lo que hice: cambié parte de la responsabilidad de decidir si este controlador debería ejecutarse para esta ruta o no, del URLResolver al controlador mismo. Resulta que URLResolver hizo mal su trabajo, y mis manejadores no solo tienen que hacer un trabajo útil, sino que también deciden si deben hacerlo. Esta es una clara violación del principio SÓLIDO de responsabilidad exclusiva . Esto no funcionará. Necesitamos mejorar.



Convertidores estándar



Django proporciona convertidores de ruta estándar . Es un mecanismo para determinar si una parte de la ruta es apropiada o no por el propio URLResolver. Una buena ventaja: el convertidor puede cambiar el tipo de parámetro, lo que significa que el tipo que necesitamos puede llegar inmediatamente al controlador, y no a la cadena.



Los convertidores se especifican antes del nombre del parámetro en la ruta, separados por dos puntos. De hecho, todos los parámetros tienen un convertidor, si no se especifica explícitamente, entonces el convertidor se usa por defecto str



.



Tenga cuidado: algunos convertidores parecen tipos en Python, por lo que puede parecer que son conversiones normales, pero no lo son; por ejemplo, no hay conversores estándar float



o bool



. Más adelante te mostraré qué es un conversor.




Después de mirar los convertidores estándar, resulta obvio para qué id



usar el convertidor int



:



path('users/<int:id>/reports/<date>/', user_report, name='user_report'),
path('teams/<int:id>/reports/<date>/', team_report, name='team_report'),
      
      







Pero ¿qué pasa con la fecha? No hay un convertidor estándar para ello.



Puede, por supuesto, esquivar y hacer esto:



'users/<int:id>/reports/<int:year>-<int:month>-<int:day>/'

      
      





De hecho, algunos de los problemas se eliminaron, porque ahora se garantiza que la fecha se mostrará en tres números separados por guiones. Sin embargo, aún debe manejar los casos problemáticos en el controlador si el cliente envía una fecha incorrecta, por ejemplo, 2021-02-29 o 100-100-100 en general. Esto significa que esta opción no es adecuada.



Creamos nuestro propio convertidor



Django, además de los convertidores estándar, ofrece la posibilidad de crear su propio convertidor y describir las reglas de conversión como desee.



Para hacer esto, debe seguir dos pasos:



  1. Describe la clase del convertidor.
  2. Registre el convertidor.


Una clase convertidora es una clase con un cierto conjunto de atributos y métodos descritos en la documentación (en mi opinión, es algo extraño que los desarrolladores no hayan creado una clase abstracta base). Los propios requisitos:



  1. Debe haber un atributo que regex



    describa la expresión regular para encontrar rápidamente la subsecuencia requerida. Te mostraré cómo se usa más adelante.
  2. Implemente un método def to_python(self, value: str)



    para convertir de una cadena (después de todo, la ruta transmitida es siempre una cadena) en un objeto de Python, que eventualmente se pasará al controlador.
  3. Implemente un método def to_url(self, value) -> str



    para volver a convertir de un objeto de Python a una cadena (usado al invocar django.urls.reverse



    o etiquetar url



    ).


La clase para convertir la fecha se verá así:



class DateConverter:
   regex = r'[0-9]{4}-[0-9]{2}-[0-9]{2}'

   def to_python(self, value: str) -> datetime:
       return datetime.strptime(value, '%Y-%m-%d')

   def to_url(self, value: datetime) -> str:
       return value.strftime('%Y-%m-%d')
      
      





Estoy en contra de la duplicación, por lo que pondré el formato de fecha en un atributo; es más fácil mantener el convertidor si de repente quiero (o necesito) cambiar el formato de fecha:



class DateConverter:
   regex = r'[0-9]{4}-[0-9]{2}-[0-9]{2}'
   format = '%Y-%m-%d'

   def to_python(self, value: str) -> datetime:
       return datetime.strptime(value, self.format)

   def to_url(self, value: datetime) -> str:
       return value.strftime(self.format)
      
      





La clase está descrita, por lo que es hora de registrarla como convertidor. Esto se hace de manera muy simple: en la función register_converter



debe especificar la clase descrita y el nombre del convertidor para poder usarlo en las rutas:



from django.urls import register_converter
register_converter(DateConverter, 'date')
      
      





Ahora puede describir las rutas en urls.py



(cambié deliberadamente el nombre del parámetro a dt



para no confundir la entrada date:date



):



path('users/<int:id>/reports/<date:dt>/', user_report, name='user_report'),
path('teams/<int:id>/reports/<date:dt>/', team_report, name='team_report'),
      
      





Ahora está garantizado que se llamará a los controladores solo si el convertidor funciona correctamente, lo que significa que los parámetros del tipo requerido llegarán al controlador:



def user_report(request, id: int, dt: datetime):
   #     
   #      
      
      





¡Parece increíble! Y esto es así, puedes comprobarlo.



Bajo el capó



Si miras de cerca, surge una pregunta interesante: en ninguna parte se comprueba que la fecha sea correcta. Sí, hay una temporada regular, pero una fecha incorrecta también es adecuada para ello, por ejemplo 2021-01-77, lo que significa que to_python



debe haber un error en ella. ¿Por qué funciona?



Sobre esto digo: "Siga las reglas del marco y jugará para usted". Los marcos asumen una serie de tareas comunes. Si el marco no puede hacer algo, entonces un buen marco brinda la oportunidad de expandir su funcionalidad. Por lo tanto, no debe participar en la construcción de bicicletas, es mejor ver cómo ofrece el marco para mejorar sus propias capacidades.



Django tiene un subsistema de enrutamiento con la capacidad de agregar convertidores que se encarga de la llamada al método to_python



y detectar errores ValueError



.



Aquí está el código del subsistema de enrutamiento Django sin cambios (versión 3.1, archivo django/urls/resolvers.py



, clase RoutePattern



, método match



):



match = self.regex.search(path)
if match:
   # RoutePattern doesn't allow non-named groups so args are ignored.
   kwargs = match.groupdict()
   for key, value in kwargs.items():
       converter = self.converters[key]
       try:
           kwargs[key] = converter.to_python(value)
       except ValueError:
           return None
   return path[match.end():], (), kwargs
return None

      
      





El primer paso es buscar coincidencias en la ruta transmitida desde el cliente utilizando una expresión regular. El regex



que está definido en la clase convertidor participa en la formación self.regex



, es decir, se sustituye en lugar de la expresión entre paréntesis angulares <>



en el recorrido.



Por ejemplo,
users/<int:id>/reports/<date:dt>/
      
      



convertirse en

^users/(?P<id>[0-9]+)/reports/(?P<dt>[0-9]{4}-[0-9]{2}-[0-9]{2})/$
      
      





Al final, lo mismo regular desde DateConverter



.



Esta es una búsqueda rápida, superficial. Si no se encuentra ninguna coincidencia, la ruta definitivamente no es adecuada, pero si se encuentra, es una ruta potencialmente adecuada. Esto significa que debe comenzar la siguiente etapa de verificación.



Cada parámetro tiene su propio convertidor, que se utiliza para llamar al método to_python



. Y aquí está lo más interesante: se to_python



envuelve la llamada try/except



y se detectan los errores de tipo ValueError



. Por eso el convertidor funciona incluso en el caso de una fecha incorrecta: cae un error ValueError



, y esto se considera para que la ruta no encaje.



Entonces en el caso de DateConverter



, podemos decir, suerte: en caso de una fecha incorrecta, se produce un error del tipo requerido. Si hay un error de otro tipo, Django devolverá una respuesta 500.



No pares



Parece que todo está bien, los convertidores están funcionando, los tipos necesarios llegan inmediatamente a los manipuladores ... ¿O no de inmediato?



path('users/<int:id>/reports/<date:dt>/', user_report, name='user_report'),
      
      





En el controlador para generar un informe, probablemente lo necesite User



, y no id



(aunque este puede ser el caso). En mi situación hipotética, solo se necesita un objeto para crear un informe User



. ¿Qué resulta entonces, de nuevo veinticinco?



def user_report(request, id: int, dt: datetime):
   user = get_object_or_404(User, id=id)
  
   # ...
      
      





Volviendo a trasladar responsabilidades al manejador.



Pero ahora está claro qué hacer con él: ¡escriba su propio convertidor! Se asegurará de que el objeto exista User



y se lo pasará al controlador.



class UserConverter:
   regex = r'[0-9]+'

   def to_python(self, value: str) -> User:
       try:
           return User.objects.get(id=value)
       except User.DoesNotExist:
           raise ValueError('not exists') #  ValueError

   def to_url(self, value: User) -> str:
       return str(value.id)
      
      





Después de describir la clase, la registro:



register_converter(UserConverter, 'user')
      
      





Finalmente, describo la ruta:



path('users/<user:u>/reports/<date:dt>/', user_report, name='user_report'),
      
      





Eso es mejor:



def user_report(request, u: User, dt: datetime):  
   # ...
      
      





Los convertidores para modelos se pueden usar con frecuencia, por lo que es conveniente hacer la clase base de dicho convertidor (al mismo tiempo, agregué una verificación de la existencia de todos los atributos):



class ModelConverter:
   regex: str = None
   queryset: QuerySet = None
   model_field: str = None

   def __init__(self):
       if None in (self.regex, self.queryset, self.model_field):
           raise AttributeError('ModelConverter attributes are not set')

   def to_python(self, value: str) -> models.Model:
       try:
           return self.queryset.get(**{self.model_field: value})
       except ObjectDoesNotExist:
           raise ValueError('not exists')

   def to_url(self, value) -> str:
       return str(getattr(value, self.model_field))

      
      





Luego, la descripción del nuevo convertidor al modelo se reducirá a una descripción declarativa:



class UserConverter(ModelConverter):
   regex = r'[0-9]+'
   queryset = User.objects.all()
   model_field = 'id'

      
      





Salir



Los convertidores de ruta son un mecanismo poderoso que le ayuda a hacer su código más limpio. Pero este mecanismo apareció solo en la segunda versión de Django, antes de eso teníamos que prescindir de él. Aquí es de donde get_object_or_404



provienen las funciones auxiliares del tipo ; sin este mecanismo, se crean bibliotecas geniales como DRF.



Pero esto no significa que los convertidores no deban utilizarse en absoluto. Esto significa que (todavía) no será posible utilizarlos en todas partes. Pero siempre que sea posible, le insto a que no los descuide.



Dejaré una advertencia: aquí es importante no exagerar y no arrastrar la manta en la otra dirección; no es necesario que lleve la lógica empresarial al convertidor. Es necesario responder a la pregunta: si tal ruta es en principio imposible, entonces esta es el área de responsabilidad del convertidor; si tal ruta es posible, pero bajo ciertas circunstancias no se procesa, entonces esto ya es responsabilidad del manejador, serializador u otra persona, pero definitivamente no del convertidor.



PD: En la práctica, he hecho y usado solo un conversor de fechas, solo el que se muestra en el artículo, ya que casi siempre uso DRF o GraphQL. Indíquenos si utiliza convertidores de ruta y, de ser así, ¿cuáles?



All Articles