Desarrollo de servidores REST en Go. Parte 1: la biblioteca estándar

Este es el primero de una serie de artículos sobre el desarrollo de servidores REST en Go. En estos artículos, planeo describir una implementación de servidor REST simple usando varios enfoques diferentes. Como resultado, estos enfoques se pueden comparar entre sí, será posible comprender sus ventajas relativas entre sí.



La primera pregunta para los desarrolladores que recién están comenzando a usar Go suele ser así: "¿Qué marco se debe usar para resolver el problema X?". Si bien esta es una pregunta perfectamente normal cuando se hace con aplicaciones web y servidores escritos en muchos otros idiomas en mente, en el caso de Go, hay muchas sutilezas a considerar al responder esta pregunta. Existen sólidos argumentos a favor y en contra del uso de frameworks en proyectos de Go. Mientras trabajo en los artículos de esta serie, veo mi meta como un estudio objetivo y versátil de este tema.



Una tarea



Para empezar, quiero decir que aquí procedo de la suposición de que el lector está familiarizado con el concepto de "servidor REST". Si necesita un repaso, eche un vistazo a este buen material (pero hay muchos otros artículos similares). De ahora en adelante, asumiré que comprenderá lo que quiero decir cuando utilizo los términos "ruta", "encabezado HTTP", "código de respuesta" y similares.



En nuestro caso, el servidor es un sistema backend simple para una aplicación que implementa la funcionalidad de administración de tareas (como Google Keep, Todoist y similares). El servidor proporciona la siguiente API REST a los clientes:



POST   /task/              :       ID
GET    /task/<taskid>      :       ID
GET    /task/              :    
DELETE /task/<taskid>      :     ID
GET    /tag/<tagname>      :       
GET    /due/<yy>/<mm>/<dd> :    ,    

      
      





Tenga en cuenta que esta API se creó específicamente para nuestro ejemplo. En las próximas entregas de esta serie, hablaremos sobre un enfoque más estructurado y estandarizado para el diseño de API.



Nuestro servidor admite solicitudes GET, POST y DELETE, algunas de ellas con la capacidad de utilizar múltiples rutas. Lo que se muestra entre corchetes angulares ( <...>



) en la descripción de la API denota parámetros que el cliente proporciona al servidor como parte de una solicitud. Por ejemplo, la solicitud está GET /task/42



dirigida a recibir una tarea del servidor con ID



42



. ID



Son identificadores únicos de tareas.



Los datos están codificados en formato JSON. Al ejecutar una solicitud POST /task/



el cliente envía una representación JSON de la tarea que se creará al servidor. Y, de manera similar, las respuestas a esas solicitudes, cuya descripción dice que "devuelven" algo, contienen datos JSON. En particular, se colocan en el cuerpo de las respuestas HTTP.



El código



A continuación, nos ocuparemos de escribir el código del servidor en Go paso a paso. La versión completa se puede encontrar aquí . Es un módulo Go autónomo que no utiliza dependencias. Después de clonar o copiar el directorio del proyecto a la computadora, el servidor puede inmediatamente, sin instalar nada adicional, ejecutar:



$ SERVERPORT=4112 go run .

      
      





Tenga en cuenta que SERVERPORT



puede utilizar cualquier puerto que escuche en el servidor local mientras espera las conexiones. Una vez que se inicia el servidor, utilizando una ventana de terminal separada, puede trabajar con él utilizando, por ejemplo, una utilidad curl



. También puede interactuar con él utilizando otros programas similares. En este script se pueden encontrar ejemplos de comandos utilizados para enviar solicitudes al servidor . El directorio que contiene este script contiene herramientas para pruebas de servidor automatizadas.



Modelo



Comencemos discutiendo el modelo (o "capa de datos") de nuestro servidor. Puede encontrarlo en el paquete taskstore



( internal/taskstore



en el directorio del proyecto). Esta es una abstracción simple que representa una base de datos que almacena tareas. Aquí está su API:



func New() *TaskStore

// CreateTask     .
func (ts *TaskStore) CreateTask(text string, tags []string, due time.Time) int

// GetTask      ID.  ID   -
//   .
func (ts *TaskStore) GetTask(id int) (Task, error)

// DeleteTask     ID.  ID   -
//   .
func (ts *TaskStore) DeleteTask(id int) error

// DeleteAllTasks     .
func (ts *TaskStore) DeleteAllTasks() error

// GetAllTasks        .
func (ts *TaskStore) GetAllTasks() []Task

// GetTasksByTag ,   ,  
//   .
func (ts *TaskStore) GetTasksByTag(tag string) []Task

// GetTasksByDueDate ,   ,  , 
//    .
func (ts *TaskStore) GetTasksByDueDate(year int, month time.Month, day int) []Task

      
      





Aquí hay una declaración de tipo Task



:



type Task struct {
  Id   int       `json:"id"`
  Text string    `json:"text"`
  Tags []string  `json:"tags"`
  Due  time.Time `json:"due"`
}

      
      





El paquete taskstore



implementa esta API usando un diccionario simple map[int]Task



y almacena los datos en la memoria. Pero no es difícil imaginar una implementación impulsada por una base de datos de esta API. En una aplicación real TaskStore



, lo más probable es que sea una interfaz que pueda ser implementada por diferentes backends. Pero para nuestro ejemplo simple, esta API es suficiente. Si quieres practicar, impleméntalo TaskStore



usando algo como MongoDB.



Preparando el servidor para el trabajo



La función de main



nuestro servidor es bastante sencilla:



func main() {
  mux := http.NewServeMux()
  server := NewTaskServer()
  mux.HandleFunc("/task/", server.taskHandler)
  mux.HandleFunc("/tag/", server.tagHandler)
  mux.HandleFunc("/due/", server.dueHandler)

  log.Fatal(http.ListenAndServe("localhost:"+os.Getenv("SERVERPORT"), mux))
}

      
      





Dediquemos un tiempo al equipo NewTaskServer



y luego hablaremos sobre el enrutador y los controladores de ruta.



NewTaskServer



Es un constructor para nuestro servidor, de tipo taskServer



. El servidor incluye TaskStore



lo que es seguro en términos de acceso concurrente a los datos .



type taskServer struct {
  store *taskstore.TaskStore
}

func NewTaskServer() *taskServer {
  store := taskstore.New()
  return &taskServer{store: store}
}

      
      





Controladores de rutas y enrutamiento



Ahora volvamos al enrutamiento. Esto usa el multiplexor HTTP estándar incluido en el paquete net/http



:



mux.HandleFunc("/task/", server.taskHandler)
mux.HandleFunc("/tag/", server.tagHandler)
mux.HandleFunc("/due/", server.dueHandler)

      
      





El multiplexor estándar tiene capacidades bastante modestas. Ésta es tanto su fuerza como su debilidad. Su punto fuerte es que es muy fácil lidiar con él, ya que no hay nada de difícil en su trabajo. Y la debilidad del multiplexor estándar es que a veces su uso hace que resolver el problema de hacer coincidir las solicitudes con las rutas disponibles en el sistema sea bastante tedioso. Lo que, según la lógica de las cosas, sería bueno estar ubicado en un lugar, hay que colocarlo en diferentes lugares. Hablaremos más sobre esto en breve.



Dado que el multiplexor estándar solo admite la coincidencia exacta de solicitudes con prefijos de ruta, estamos prácticamente obligados a confiar solo en las rutas raíz en el nivel superior y delegar la tarea de encontrar la ruta exacta a los controladores de ruta.



Examinemos el controlador de ruta taskHandler



:



func (ts *taskServer) taskHandler(w http.ResponseWriter, req *http.Request) {
  if req.URL.Path == "/task/" {
    //    "/task/",     ID.
    if req.Method == http.MethodPost {
      ts.createTaskHandler(w, req)
    } else if req.Method == http.MethodGet {
      ts.getAllTasksHandler(w, req)
    } else if req.Method == http.MethodDelete {
      ts.deleteAllTasksHandler(w, req)
    } else {
      http.Error(w, fmt.Sprintf("expect method GET, DELETE or POST at /task/, got %v", req.Method), http.StatusMethodNotAllowed)
      return
    }

      
      





Comenzamos verificando una coincidencia exacta de la ruta con /task/



(lo que significa que no existe al final <taskid>



). Aquí debemos entender qué método HTTP se está utilizando y llamar al método de servidor correspondiente. La mayoría de los controladores de ruta son envoltorios de API bastante simples TaskStore



. Veamos uno de estos controladores:



func (ts *taskServer) getAllTasksHandler(w http.ResponseWriter, req *http.Request) {
  log.Printf("handling get all tasks at %s\n", req.URL.Path)

  allTasks := ts.store.GetAllTasks()
  js, err := json.Marshal(allTasks)
  if err != nil {
    http.Error(w, err.Error(), http.StatusInternalServerError)
    return
  }
  w.Header().Set("Content-Type", "application/json")
  w.Write(js)
}

      
      





Resuelve dos tareas principales:



  1. Recibe datos del modelo ( TaskStore



    ).
  2. Genera una respuesta HTTP para el cliente.


Ambas tareas son bastante simples y directas, pero si examina el código de otros controladores de ruta, puede notar que la segunda tarea tiende a repetirse: consiste en calcular datos JSON, preparar el encabezado de respuesta HTTP correcto y en realizando otras acciones similares ... Volveremos a plantear este problema más adelante.



Volvamos ahora a taskHandler



. Hasta ahora, solo hemos visto cómo maneja las solicitudes que tienen una ruta de acceso exacta /task/



. ¿Y el camino /task/<taskid>



? Aquí es donde entra la segunda parte de la función:



} else {
  //    ID,    "/task/<id>".
  path := strings.Trim(req.URL.Path, "/")
  pathParts := strings.Split(path, "/")
  if len(pathParts) < 2 {
    http.Error(w, "expect /task/<id> in task handler", http.StatusBadRequest)
    return
  }
  id, err := strconv.Atoi(pathParts[1])
  if err != nil {
    http.Error(w, err.Error(), http.StatusBadRequest)
    return
  }

  if req.Method == http.MethodDelete {
    ts.deleteTaskHandler(w, req, int(id))
  } else if req.Method == http.MethodGet {
    ts.getTaskHandler(w, req, int(id))
  } else {
    http.Error(w, fmt.Sprintf("expect method GET or DELETE at /task/<id>, got %v", req.Method), http.StatusMethodNotAllowed)
    return
  }
}

      
      





Cuando la consulta no coincide exactamente con la ruta /task/



, esperamos que el ID



problema numérico siga a la barra inclinada . El código anterior analiza este ID



y llama al controlador apropiado (según el método de solicitud HTTP).



El resto del código es más o menos similar al que ya hemos cubierto, debería ser fácil de entender.



Mejora del servidor



Ahora que tenemos una versión básica funcional del servidor, es hora de pensar en los posibles problemas que podrían surgir con él y cómo mejorarlo.



Una de las construcciones de programación que usamos y que obviamente necesita mejorar, y de la que ya hemos hablado, es el código repetitivo para preparar datos JSON al generar respuestas HTTP. Creé una versión separada del servidor, stdlib-factorjson , que resuelve este problema. He separado esta implementación del servidor en una carpeta separada para que sea más fácil compararla con el código del servidor original y analizar los cambios. La principal innovación de este código está representada por la siguiente función:



// renderJSON  'v'   JSON   ,   ,  w.
func renderJSON(w http.ResponseWriter, v interface{}) {
  js, err := json.Marshal(v)
  if err != nil {
    http.Error(w, err.Error(), http.StatusInternalServerError)
    return
  }
  w.Header().Set("Content-Type", "application/json")
  w.Write(js)
}

      
      





Usando esta función, podemos reescribir el código de todos los manejadores de ruta, acortándolo. Por ejemplo, así es como se ve el código ahora getAllTasksHandler



:



func (ts *taskServer) getAllTasksHandler(w http.ResponseWriter, req *http.Request) {
  log.Printf("handling get all tasks at %s\n", req.URL.Path)

  allTasks := ts.store.GetAllTasks()
  renderJSON(w, allTasks)
}

      
      





Una mejora más fundamental sería hacer más limpio el código de mapeo de solicitud a ruta y, si es posible, recopilar este código en un solo lugar. Si bien el enfoque actual para hacer coincidir solicitudes y rutas facilita la depuración, el código subyacente es difícil de entender a primera vista, ya que se encuentra disperso en múltiples funciones. Por ejemplo, suponga que estamos tratando de averiguar cómo una solicitud DELETE



que se dirige a un /task/<taskid>



. Para hacer esto, siga estos pasos:



  1. - — main



    , /task/



    taskHandler



    .
  2. , taskHandler



    , else



    , , /task/



    . <taskid>



    .
  3. if



    , , , , , DELETE



    deleteTaskHandler



    .


Puedes poner todo este código en un solo lugar. Será mucho más fácil y conveniente trabajar con él. Esto es exactamente a lo que apuntan los enrutadores HTTP de terceros. Hablaremos de ellos en la segunda parte de esta serie de artículos.



Esta es la primera parte de una serie sobre el desarrollo de servidores Go. Puede ver la lista de artículos al comienzo del original de este material.








All Articles