En el proceso de conocer Golang, decidí crear el marco de la aplicación, lo que me será conveniente para trabajar en el futuro. El resultado fue, en mi opinión, una buena pieza de trabajo, que decidí compartir, y al mismo tiempo discutir los momentos que surgieron durante la creación del marco.

En principio, el diseño del lenguaje Go sugiere que no es necesario hacer aplicaciones a gran escala (estoy hablando de la falta de genéricos y el mecanismo de manejo de errores no muy poderoso). Pero todavía sabemos que el tamaño de las aplicaciones generalmente no disminuye, pero más a menudo es todo lo contrario. Por lo tanto, es mejor crear de inmediato un marco en el que sea posible encadenar nuevas funciones sin sacrificar el soporte de código.
Intenté insertar menos código en el artículo, en su lugar agregué enlaces a líneas de código específicas en Github con la esperanza de que fuera más conveniente ver la imagen completa.
Primero, bosquejé un plan para lo que debería estar en la aplicación. Como en el artículo hablaré sobre cada elemento por separado, primero daré el principal de esta lista como contenido.
- Elegir administrador de paquetes
- Elija un marco para crear una API
- Seleccionar herramienta para inyección de dependencia (DI)
- Rutas de solicitud web
- Respuestas JSON / XML según encabezados de solicitud
- ORM
- Migraciones
- Hacer clases base para capas de modelo Servicio-> Repositorio-> Entidad
- Depósito CRUD básico
- Servicio CRUD básico
- Controlador CRUD básico
- Validación de solicitud
- Configuraciones y variables de entorno
- Comandos de la consola
- Registro
- Integración de registrador con Sentry u otro sistema de alerta
- Establecer alerta de errores
- Pruebas unitarias con redefinición de servicios a través de DI
- Mapa de códigos de cobertura de porcentaje y prueba
- Swagger
- Docker componer
Administrador de paquetes
Después de leer las descripciones de varias implementaciones, elegí govendor y por el momento estaba satisfecho con la elección. La razón es simple: le permite instalar dependencias dentro del directorio con la aplicación, almacenar información sobre paquetes y sus versiones.
La información sobre los paquetes y sus versiones se almacena en un archivo vendor.json. También hay un punto negativo en este enfoque. Si agrega un paquete con sus dependencias, junto con la información sobre el paquete, la información sobre sus dependencias también se incluirá en el archivo. El archivo crece rápidamente y ya no es posible determinar claramente qué dependencias son las principales y cuáles son las derivadas.
En PHP Composer o en npm, las dependencias principales se describen en un archivo, y todas las dependencias principales y derivadas y sus versiones se registran automáticamente en el archivo de bloqueo. Este enfoque es más conveniente en mi opinión. Pero por ahora, la implementación del gobierno ha sido suficiente para mí.
Marco
Desde el marco no necesito mucho, un enrutador conveniente, validación de solicitudes. Todo esto se encontró en la popular Gin . Se detuvo en eso.
Inyección de dependencia
Con DI tuve que sufrir un poco. Primero elegí Dig. Y al principio todo fue genial. Servicios descritos, Dig construye más dependencias, convenientemente. Pero luego resultó que los servicios no se pueden redefinir, por ejemplo, durante las pruebas. Por lo tanto, al final, llegué a la conclusión de que tomé un contenedor de servicio simple sarulabs / di .
Solo tuve que bifurcarlo, porque listo para usar le permite agregar servicios y prohíbe redefinirlos. Y al escribir autotests, en mi opinión, es más conveniente inicializar el contenedor como en la aplicación, y luego redefinir algunos de los servicios, especificando stubs en su lugar. En fork, agregó un método para anular la descripción del servicio.
Pero al final, tanto en el caso de Dig como en el caso del contenedor de servicio, tuve que poner las pruebas en un paquete separado. De lo contrario, resulta que las pruebas se ejecutan por separado en paquetes ( go test model/service
), pero no se inician de inmediato para toda la aplicación ( go test ./...
), debido a las dependencias cíclicas que surgen en este caso.
En Gin, no encontré esto, así que simplemente agregué un método al controlador base que genera una respuesta dependiendo del encabezado de la solicitud.
func (c BaseController) response(context *gin.Context, obj interface{}, code int) { switch context.GetHeader("Accept") { case "application/xml": context.XML(code, obj) default: context.JSON(code, obj) } }
ORM
Con ORM no sentí el largo tormento de elección. Había mucho para elegir. Pero de acuerdo con la descripción de las características, me gustó GORM, que es uno de los más populares en el momento de la selección. Hay soporte para el DBMS más utilizado. Al menos PostgreSQL y MySQL definitivamente están ahí. También tiene métodos para administrar el esquema base que puede usar al crear migraciones.
Migraciones
Para las migraciones, me decidí por el paquete gorm-goose . Pongo un paquete separado a nivel mundial y empiezo la migración a él. Al principio, tal implementación se avergonzó, ya que la conexión a la base de datos tuvo que describirse en un archivo db / dbconf.yml separado. Pero luego resultó que la cadena de conexión puede describirse de tal manera que el valor se toma de la variable de entorno.
development: driver: postgres open: $DB_URL
Y esto es bastante conveniente. Al menos con docker-compose, no tuve que duplicar la cadena de conexión .
Gorm-goose también admite reversiones de migración, lo que me parece muy útil.
Depósito CRUD básico
Prefiero que todo lo que se refiere a recursos se coloque en una capa de repositorio separada. En mi opinión, con este enfoque, el código de lógica de negocios es más limpio. En este caso, el código de lógica de negocios solo sabe que necesita trabajar con los datos que toma del repositorio. Y lo que sucede en el repositorio, la lógica empresarial no es importante. El repositorio puede funcionar con una base de datos relacional, con un almacenamiento KV, con un disco o quizás con la API de otro servicio. El código de lógica de negocios será el mismo en todos estos casos.
El repositorio CRUD implementa la siguiente interfaz
type CrudRepositoryInterface interface { BaseRepositoryInterface GetModel() (entity.InterfaceEntity) Find(id uint) (entity.InterfaceEntity, error) List(parameters ListParametersInterface) (entity.InterfaceEntity, error) Create(item entity.InterfaceEntity) entity.InterfaceEntity Update(item entity.InterfaceEntity) entity.InterfaceEntity Delete(id uint) error }
Es decir, CRUD implementa las operaciones Create()
, Find()
, List()
, Update()
, Delete()
y el método GetModel()
.
Sobre GetModel () . Existe un repositorio CrudRepository
básico que implementa operaciones CRUD básicas. En los repositorios que lo integran, es suficiente indicar con qué modelo deberían trabajar. Para hacer esto, el método GetModel()
debe devolver un modelo GORM. Luego tuvimos que usar el resultado de GetModel()
usando la reflexión en los métodos CRUD.
Por ejemplo
func (c CrudRepository) Find(id uint) (entity.InterfaceEntity, error) { item := reflect.New(reflect.TypeOf(c.GetModel()).Elem()).Interface() err := c.db.First(item, id).Error return item, err }
Es decir, de hecho, en este caso fue necesario abandonar la escritura estática en favor de la escritura dinámica. En esos momentos, se siente especialmente la falta de genéricos en el idioma.
Para que los repositorios que trabajan con modelos específicos implementen sus propias reglas para filtrar listas en el método List()
, primero implementé el enlace tardío para que el método responsable de construir la consulta se llame desde el método List()
. Y este método podría implementarse en un repositorio específico. Es difícil abandonar de alguna manera los patrones de pensamiento que se formaron al trabajar con otros idiomas. Pero, mirándolo con una mirada fresca y apreciando la "elegancia" del camino elegido, luego lo rehizo a un enfoque más cercano a Go. Para hacer esto, simplemente en CrudRepository
través de la interfaz se declara un generador de consultas , que ya se List()
.
listQueryBuilder ListQueryBuilderInterface
Resulta bastante gracioso. Limitar el lenguaje a un enlace tardío, que al principio parece una falla, fomenta una separación más clara del código.
Servicio CRUD básico
Aquí no hay nada interesante, ya que no existe una lógica de negocios en el marco. Las llamadas de los métodos CRUD al repositorio son simplemente aproximadas .
En la capa de servicios, se debe implementar la lógica de negocios.
Controlador CRUD básico
El controlador implementa métodos CRUD . Procesan los parámetros de la solicitud, el control se transfiere al método de servicio correspondiente y, en función de la respuesta del servicio, se forma una respuesta para el cliente.
Con el controlador tuve la misma historia que con el repositorio con respecto a las listas de filtrado. Como resultado, rehice la implementación con un enlace tardío casero y agregué un hidratante que, según los parámetros de solicitud, forma una estructura con parámetros para filtrar la lista.
En el hidratador que viene con el controlador CRUD, solo se procesan los parámetros de paginación. En los controladores específicos en los que está integrado el controlador CRUD, el hidratador se puede redefinir .
Validación de solicitud
La validación es realizada por Gin. Por ejemplo, al agregar un registro (método Create()
), es suficiente decorar los elementos de la estructura de la entidad
Name string `binding:"required"`
El método ShouldBindJSON()
del marco se encarga de verificar que los parámetros de la solicitud cumplan con los requisitos descritos en el decorador.
Configuraciones y variables de entorno
Realmente me gustó la implementación de Viper , especialmente en combinación con Cobra.
Leyendo la configuración que describí en main.go. Los parámetros básicos que no contienen secretos se describen en el archivo base.env . Puede anularlos en el archivo .env que se agrega a .gitignore. En .env, puede describir valores secretos para el entorno.
Las variables de entorno tienen una mayor prioridad.
Comandos de la consola
Para la descripción de los comandos de la consola, elegí Cobra . Entonces es bueno usar Cobra junto con Viper. Podemos describir el comando
serverCmd.PersistentFlags().StringVar(&serverPort, "port", defaultServerPort, "Server port")
Y vincule la variable de entorno al valor del parámetro de comando
viper.BindPFlag("SERVER_PORT", serverCmd.PersistentFlags().Lookup("port"))
De hecho, toda la aplicación de este marco es la consola. El servidor web se inicia mediante uno de los comandos de la consola del servidor.
gin -i run server
Registro
Elegí el paquete logrus para el registro , ya que hay todo lo que generalmente necesito: establecer niveles de registro, dónde registrar, agregar ganchos, por ejemplo, para enviar registros al sistema de alerta.
Integración de registrador con sistema de alerta
Elegí Sentry, porque todo resultó ser bastante simple gracias a la fácil integración con logrus: logrus_sentry . Hice los parámetros con la url a Sentry SENTRY_DSN
y el tiempo de espera para enviar a Sentry SENTRY_TIMEOUT
. Resultó que, por defecto, el tiempo de espera es pequeño, si no es erróneo, 300 ms, y muchos mensajes no se entregaron.
Establecer alerta de errores
Realicé el procesamiento de pánico por separado para el servidor web y los comandos de la consola .
Pruebas unitarias con redefinición de servicios a través de DI
Como se señaló anteriormente, se tuvo que asignar un paquete separado para las pruebas unitarias. Como la biblioteca seleccionada para crear un contenedor de servicios no permitía redefinir servicios, en fork se agregó un método para redefinir la descripción de los servicios. Debido a esto, en la prueba unitaria, puede usar la misma descripción de servicios que en la aplicación
dic.InitBuilder()
Y redefina solo algunas descripciones de servicio en trozos de esta manera
dic.Builder.Set(di.Def{ Name: dic.UserRepository, Build: func(ctn di.Container) (interface{}, error) { return NewUserRepositoryMock(), nil }, })
A continuación, puede crear un contenedor y utilizar los servicios necesarios en la prueba:
dic.Container = dic.Builder.Build() userService := dic.Container.Get(dic.UserService).(service.UserServiceInterface)
Por lo tanto, probaremos userService, que en lugar del repositorio real usará el código auxiliar proporcionado.
Mapa de códigos de cobertura de porcentaje y prueba
Estaba completamente satisfecho con la utilidad de prueba estándar.
Puedes ejecutar pruebas individualmente
go test test/unit/user_service_test.go -v
Puedes ejecutar todas las pruebas a la vez
go test ./... -v
Puede crear un mapa de cobertura y calcular el porcentaje de cobertura.
go test ./... -v -coverpkg=./... -coverprofile=coverage.out
Y vea un mapa de cobertura de código con pruebas en un navegador
go tool cover -html=coverage.out
Swagger
Hay un proyecto gin-swagger para Gin, que se puede utilizar tanto para generar especificaciones para Swagger como para generar documentación basada en él. Pero, como resultó, para generar especificaciones para operaciones específicas, es necesario indicar comentarios sobre funciones específicas del controlador. Esto resultó no ser muy conveniente para mí, ya que no quería duplicar el código de operaciones CRUD en cada controlador. En cambio, en controladores específicos, simplemente inserto un controlador CRUD como se describe anteriormente. Realmente tampoco quería crear funciones de código auxiliar para esto.
Por lo tanto, llegué a la conclusión de que la especificación se genera utilizando goswagger , porque en este caso las operaciones pueden describirse sin estar vinculadas a funciones específicas .
swagger generate spec -o doc/swagger.yml
Por cierto, con goswagger incluso podría pasar de lo contrario y generar el código del servidor web basado en la especificación Swagger. Pero con este enfoque, hubo dificultades con el uso de ORM, y finalmente lo abandoné.
La documentación se genera utilizando gin-swagger, para esto se indica un archivo de especificación pregenerado.
Docker componer
En el marco, agregué una descripción de dos contenedores: para el código y para la base . Al comienzo del contenedor con el código, esperamos hasta que el contenedor con la base se inicie por completo. Y en cada inicio, hacemos migraciones si es necesario. Los parámetros para conectarse a la base de datos para migraciones se describen, como se mencionó anteriormente, en dbconf.yml , donde fue posible usar la variable de entorno para transferir la configuración para conectarse a la base de datos.
Gracias por su atencion En el proceso, tuve que adaptarme a las características del lenguaje. Me interesaría saber la opinión de colegas que pasaron más tiempo con Go. Seguramente algunos momentos podrían hacerse más elegantes, por lo que estaré encantado de recibir críticas útiles. Enlace al marco: https://github.com/zubroide/go-api-boilerplate