Go-swagger como marco para la interacción de microservicios





¡Hola NickName! Si es programador y trabaja con una arquitectura de microservicio, entonces imagine que necesita configurar la interacción de su servicio A con algún servicio B nuevo y aún desconocido. ¿Qué hará primero?



Si hace esta pregunta a 100 programadores de diferentes empresas, lo más probable es que obtengamos 100 respuestas diferentes. Alguien describe los contratos con arrogancia, alguien en gRPC simplemente hace clientes a sus servicios sin describir un contrato. Y alguien incluso almacena JSON en un googleok: D. La mayoría de las empresas desarrollan su propio enfoque para la interacción entre servicios en función de algunos factores históricos, competencias, pila tecnológica, etc. Quiero contarles cómo los servicios de Delivery Club se comunican entre sí y por qué tomamos esa decisión. Y lo más importante, cómo aseguramos la relevancia de la documentación a lo largo del tiempo. ¡Habrá mucho código!



¡Hola de nuevo! Mi nombre es Sergey Popov, soy el líder del equipo responsable de los resultados de búsqueda de restaurantes en las aplicaciones y en el sitio web del Delivery Club, y también soy miembro activo de nuestro gremio de desarrollo interno para Go (es posible que hablemos de esto más adelante, pero no ahora).



Haré una reserva de inmediato, principalmente hablaremos de los servicios escritos en Go. Aún no hemos implementado la generación de código para los servicios PHP, aunque logramos uniformidad en los enfoques de una manera diferente.



Con lo que queríamos terminar:



  1. Asegúrese de que los contratos de servicio estén actualizados. Esto debería acelerar la introducción de nuevos servicios y facilitar la comunicación entre equipos.
  2. Llegue a un método unificado de interacción a través de HTTP entre servicios (no consideraremos interacciones a través de colas y transmisión de eventos por ahora).
  3. Estandarizar el enfoque para trabajar con contratos de servicios.
  4. Utilice un repositorio único de contratos para no buscar muelles para todo tipo de confluencias.
  5. Lo ideal es generar clientes para diferentes plataformas.


De todo lo anterior, Protobuf viene a la mente como una forma unificada de describir contratos. Tiene buenas herramientas y puede generar clientes para diferentes plataformas (nuestra cláusula 5). Pero también hay inconvenientes obvios: para muchos, gRPC sigue siendo algo nuevo y desconocido, y esto complicaría enormemente su implementación. Otro factor importante fue que la empresa había adoptado durante mucho tiempo el enfoque de "especificación primero", y la documentación ya existía para todos los servicios en forma de fanfarronería o descripción RAML.



Go-swagger



Casualmente, al mismo tiempo, comenzamos a adaptar Go en la empresa. Por lo tanto, nuestro próximo candidato a considerar fue go-swagger , una herramienta que le permite generar clientes y código de servidor a partir de la especificación swagger. La desventaja obvia es que solo genera código para Go. De hecho, usa la generación de código gosh y go-swagger permite trabajar de manera flexible con plantillas, por lo que en teoría se puede usar para generar código PHP, pero aún no lo hemos probado.



Go-swagger no se trata solo de la generación de capas de transporte. De hecho, genera el esqueleto de la aplicación, y aquí me gustaría mencionar un poco sobre la cultura del desarrollo en DC. Tenemos una fuente interna, lo que significa que cualquier desarrollador de cualquier equipo puede crear una solicitud de extracción a cualquier servicio que tengamos. Para que un esquema de este tipo funcione, intentamos estandarizar los enfoques en el desarrollo: usamos terminología común, un enfoque único para el registro, métricas, trabajo con dependencias y, por supuesto, la estructura del proyecto.



Así, al implementar go-swagger, estamos introduciendo un estándar para el desarrollo de nuestros servicios en Go. Este es un paso más hacia nuestras metas, que inicialmente no esperábamos, pero que es importante para el desarrollo en general.



Los primeros pasos



Así que go-swagger resultó ser un candidato interesante que parece ser capaz de cubrir la mayoría de nuestros requisitos deseados .

Nota: todo el código adicional es relevante para la versión 0.24.0, las instrucciones de instalación se pueden encontrar en nuestro repositorio con ejemplos , y el sitio web oficial tiene instrucciones para instalar la versión actual.
Veamos qué puede hacer. Tomemos una especificación de arrogancia y generemos un servicio:



> goswagger generate server \
    --with-context -f ./swagger-api/swagger.yml \
    --name example1


Tenemos lo siguiente:







Makefile y go.mod Ya lo hice yo mismo.



De hecho, terminamos con un servicio que procesa las solicitudes descritas en swagger.



> go run cmd/example1-server/main.go
2020/02/17 11:04:24 Serving example service at http://127.0.0.1:54586
 
 
 
> curl http://localhost:54586/hello -i
HTTP/1.1 501 Not Implemented
Content-Type: application/json
Date: Sat, 15 Feb 2020 18:14:59 GMT
Content-Length: 58
Connection: close
 
"operation hello HelloWorld has not yet been implemented"


Segundo paso. Entendiendo las plantillas



Obviamente, el código que generamos está lejos de lo que queremos ver en funcionamiento.



Qué queremos de la estructura de nuestra aplicación:



  • Ser capaz de configurar la aplicación: transferir la configuración para conectarse a la base de datos, especificar el puerto de conexiones HTTP, etc.
  • Seleccione un objeto de aplicación que almacenará el estado de la aplicación, la conexión a la base de datos, etc.
  • Hacer que los manejadores sean funciones de nuestra aplicación, esto debería simplificar el trabajo con el código.
  • Inicialice las dependencias en el archivo principal (en nuestro ejemplo esto no sucederá, pero aún queremos esto.


Para resolver nuevos problemas, podemos anular algunas plantillas. Para hacer esto, describiremos los siguientes archivos, como hice yo ( Github ):







Necesitamos describir los archivos de plantilla ( `*.gotmpl`) y el archivo para la configuración ( `*.yml`) de generar nuestro servicio.



A continuación, en orden, analizaremos las plantillas que hice. No profundizaré en trabajar con ellos, porque la documentación de go-swagger es bastante detallada, por ejemplo, aquí está la descripción del archivo de configuración. Solo señalaré que se usa Go-templates, y si ya tiene experiencia con esto o tuvo que describir configuraciones HELM, entonces no será difícil resolverlo.



Configurar la aplicación



config.gotmpl contiene una estructura simple con un parámetro: el puerto que la aplicación escuchará para las solicitudes HTTP entrantes. También hice una función InitConfigque leerá las variables de entorno y completará esta estructura. Lo llamaré desde main.go, así que lo InitConfigconvertí en una función pública.



package config
 
import (
    "github.com/pkg/errors"
    "github.com/vrischmann/envconfig"
)
 
// Config struct
type Config struct {
    HTTPBindPort int `envconfig:"default=8001"`
}
 
// InitConfig func
func InitConfig(prefix string) (*Config, error) {
    config := &Config{}
    if err := envconfig.InitWithPrefix(config, prefix); err != nil {
        return nil, errors.Wrap(err, "init config failed")
    }
 
    return config, nil
}


Para que esta plantilla se utilice al generar código, debe especificarla en la configuración de YML :



layout:
  application:
    - name: cfgPackage
      source: serverConfig
      target: "./internal/config/"
      file_name: "config.go"
      skip_exists: false


Te cuento un poco sobre los parámetros:



  • name - Tiene una función meramente informativa y no afecta a la generación.
  • source- en realidad, la ruta al archivo de plantilla en camelCase, es decir, serverConfig es equivalente a ./server/config.gotmpl .
  • target- directorio donde se guardará el código generado. Aquí puede usar plantillas para generar dinámicamente una ruta ( ejemplo ).
  • file_name - el nombre del archivo generado, aquí también puede utilizar plantillas.
  • skip_exists- una señal de que el archivo se generará solo una vez y no sobrescribirá el existente. Esto es importante para nosotros, porque el archivo de configuración cambiará a medida que la aplicación crezca y no debería depender del código generado.


En la configuración de generación de código, debe especificar todos los archivos, y no solo aquellos que queremos anular. Para los archivos que no cambiamos, en el sentido del sourcepunto de salida asset:< >, por ejemplo, aquí : asset:serverConfigureapi. Por cierto, si está interesado en mirar las plantillas originales, aquí están .



Objeto de aplicación y controladores



No describiré el objeto de la aplicación para almacenar el estado, las conexiones de la base de datos y otras cosas, todo es similar a la configuración recién creada. Pero con los manipuladores, todo es un poco más interesante. Nuestro objetivo clave es que creemos una función stub en un archivo separado cuando agregamos una URL a la especificación y, lo más importante, que nuestro servidor llame a esta función para procesar la solicitud.



Describamos la plantilla de función y los stubs:



package app
 
import (
    api{{ pascalize .Package }} "{{.GenCommon.TargetImportPath}}/{{ .RootPackage }}/operations/{{ .Package }}"
    "github.com/go-openapi/runtime/middleware"
)
 
func (srv *Service){{ pascalize .Name }}Handler(params api{{ pascalize .Package }}.{{ pascalize .Name }}Params{{ if .Authorized }}, principal api{{ .Package }}.{{ if not ( eq .Principal "interface{}" ) }}*{{ end }}{{ .Principal }}{{ end }}) middleware.Responder {
    return middleware.NotImplemented("operation {{ .Package }} {{ pascalize .Name }} has not yet been implemented")
}


Veamos un poco un ejemplo:



  • pascalize- trae una línea con CamelCase (descripción de otras funciones aquí ).
  • .RootPackage - paquete de servidor web generado.
  • .Package- el nombre del paquete en el código generado, que describe todas las estructuras necesarias para las solicitudes y respuestas HTTP, es decir, estructuras. Por ejemplo, una estructura para el cuerpo de la solicitud o una estructura de respuesta.
  • .Name- el nombre del manipulador. Se toma del ID de operación en la especificación, si se especifica. Recomiendo especificar siempre operationIDpara obtener un resultado más obvio.


La configuración del controlador es la siguiente:



layout:
  operations:
    - name: handlerFns
      source: serverHandler
      target: "./internal/app"
      file_name: "{{ (snakize (pascalize .Name)) }}.go"
      skip_exists: true


Como puede ver, el código del controlador no se sobrescribirá ( skip_exists: true) y el nombre del archivo se generará a partir del nombre del controlador.



De acuerdo, hay una función stub, pero el servidor web aún no sabe que estas funciones deben usarse para procesar solicitudes. Arreglé esto en main.go (no daré el código completo, la versión completa se puede encontrar aquí ):



package main
 
{{ $name := .Name }}
{{ $operations := .Operations }}
import (
    "fmt"
    "log"
 
    "github.com/delivery-club/go-swagger-example/{{ dasherize .Name }}/internal/generated/restapi"
    "github.com/delivery-club/go-swagger-example/{{ dasherize .Name }}/internal/generated/restapi/operations"
    {{range $index, $op := .Operations}}
        {{ $found := false }}
        {{ range $i, $sop := $operations }}
            {{ if and (gt $i $index ) (eq $op.Package $sop.Package)}}
                {{ $found = true }}
            {{end}}
        {{end}}
        {{ if not $found }}
        api{{ pascalize $op.Package }} "{{$op.GenCommon.TargetImportPath}}/{{ $op.RootPackage }}/operations/{{ $op.Package }}"
        {{end}}
    {{end}}
 
    "github.com/go-openapi/loads"
    "github.com/vrischmann/envconfig"
 
    "github.com/delivery-club/go-swagger-example/{{ dasherize .Name }}/internal/app"
)
 
func main() {
    ...
    api := operations.New{{ pascalize .Name }}API(swaggerSpec)
 
    {{range .Operations}}
    api.{{ pascalize .Package }}{{ pascalize .Name }}Handler = api{{ pascalize .Package }}.{{ pascalize .Name }}HandlerFunc(srv.{{ pascalize .Name }}Handler)
    {{- end}}
    ...
}


El código en la importación parece complicado, aunque en realidad es solo plantillas Go y estructuras del repositorio go-swagger. Y en una función, mainsimplemente asignamos nuestras funciones generadas a los controladores.



Queda por generar el código indicando nuestra configuración:



> goswagger generate server \
        -f ./swagger-api/swagger.yml \
        -t ./internal/generated -C ./swagger-templates/default-server.yml \
        --template-dir ./swagger-templates/templates \
        --name example2


El resultado final se puede ver en nuestro repositorio .



Lo que tenemos:



  • Podemos usar nuestras estructuras para la aplicación, configuraciones y lo que queramos. Lo más importante es que es bastante fácil de incrustar en el código generado.
  • Podemos gestionar de forma flexible la estructura del proyecto, hasta los nombres de los archivos individuales.
  • Utilizar plantillas parece complicado y requiere algo de tiempo para acostumbrarse, pero en general es una herramienta muy poderosa.


Paso tres. Generando clientes



Go-swagger también nos permite generar un paquete de cliente para nuestro servicio que pueden usar otros servicios de Go. Aquí no me detendré en la generación de código en detalle, el enfoque es exactamente el mismo que al generar código del lado del servidor.



Para los proyectos de Go, es costumbre poner paquetes públicos ./pkg, nosotros haremos lo mismo: ponemos el cliente de nuestro servicio en pkg y genera el código en sí de la siguiente manera:



> goswagger generate client -f ./swagger-api/swagger.yml -t ./pkg/example3


Un ejemplo del código generado está aquí .



Ahora todos los consumidores de nuestro servicio pueden importar este cliente por sí mismos, por ejemplo, por etiqueta (para mi ejemplo, la etiqueta será example3/pkg/example3/v0.0.1).



Las plantillas de cliente se pueden personalizar para, por ejemplo, pasar open tracing iddel contexto al encabezado.



conclusiones



Naturalmente, nuestra implementación interna difiere del código que se muestra aquí principalmente debido al uso de paquetes internos y enfoques de CI (ejecutando varias pruebas y linters). En el código generado fuera de la caja, se configura la recopilación de métricas técnicas, el trabajo con las configuraciones y el registro. Hemos estandarizado todas las herramientas habituales. Debido a esto, simplificamos el desarrollo en general y el lanzamiento de nuevos servicios en particular, aseguramos un paso más rápido de la lista de verificación del servicio antes de implementarlo en el producto.



Comprobemos si hemos logrado nuestros objetivos iniciales:



  1. Asegurar la relevancia de los contratos descritos para los servicios, esto debería acelerar la introducción de nuevos servicios y simplificar la comunicación entre equipos - .
  2. HTTP ( event streaming) — .
  3. , .. Inner Source — .
  4. , — ( — Bitbucket).
  5. , — ( , , ).
  6. Go — ( ).


El lector atento probablemente ya se haya hecho la pregunta: ¿cómo entran los archivos de plantilla en nuestro proyecto? Ahora los almacenamos en cada uno de nuestros proyectos. Esto simplifica el trabajo diario, le permite personalizar algo para un proyecto específico. Pero hay otra cara de la moneda: no existe un mecanismo para la actualización centralizada de plantillas y la entrega de nuevas funciones, principalmente relacionadas con CI.



PD Si te gusta este material, en el futuro prepararemos un artículo sobre la arquitectura estándar de nuestros servicios, te diremos qué principios usamos al desarrollar servicios en Go.



All Articles