Número 72
8 de novembro de 2023

Separar os responsables

Levo uns poucos números a describir como fago un sistema de comentarios para a Folla. Resulta que facer algo e despois escribir como o fixen leva máis tempo e dá máis traballo que escribir sobre as cousas nas que pensei aleatoriamente durante a semana!

Na Folla anterior fixen un prototipo que consistía nun compoñente en JavaScript e un servidor web escrito en Go; o compoñente facía unha petición fetch ao servidor web e este respondía cun pouco JSON que contiña unha lista de comentarios.

A idea que teño para o meu sistema é que haxa unha base de datos que garda os comentarios e o servizo web acceda a ela. Como o que teño polo momento é un prototipo, a lista de comentarios está metida a machete no código fonte do servidor web. Imos ter que facer cambios.

func ListComments(rw http.ResponseWriter, req *http.Request) {
  comments := CommentList{
    PostId: "1234",
    Comments: []Comment{
      {Id: "1", Text: "A", Author: "Jacobo", Date: "1 de xaneiro"},
      {Id: "2", Text: "saia", Author: "Pepiño", Date: "17 de maio"},
      {Id: "3", Text: "da", Author: "Susana", Date: "25 de xullo"},
      {Id: "4", Text: "Carolina", Author: "Olalla", Date: "Nadal"},
    }}
  output, err := json.Marshal(comments)
  if err != nil { ... }
  rw.Header().Add("Content-Type", "text/json")
  rw.Write(output)
}

Moitísima xente cae na tentación de poñer o código que accede á base de datos directamente no código do servizo. Hai moreas e moreas de titoriais de programación que fan iso, centos de programas (open source e dos outros) que tamén os mesturan, e miles de programadores que non ven o problema niso. Todos eles están errados.

A separación de responsabilidades (“separation of concerns” en inglés) é un principio de deseño de software moi importante. O que di é que as partes do código que fan cousas distintas deberían estar separadas.

Como describiriades unha función que accede á base de datos e responde á petición web? Pois talmente: “esta función consulta a base de datos e convirte o resultado a JSON para envialo ao navegador”. A Folla número 25 divos o que facer coas funcións que teñen a conxunción “e” na descrición. Facédelle caso ao autor, que sabe cousas.

A maneira “estándar” de facer esta separación é crear unha interface con funcións de alto nivel para acceder aos datos. No meu caso, esta interface tería unha función para ler a lista de comentarios dunha historia e no futuro eu engadiría funcións para engadir un comentario, modificar un comentario existente ou borrar comentarios.

Con isto só teño que implementar esta interface e usala no meu servizo. Como ás veces teño ben pouca imaxinación, no código fonte de embaixo chameina DataSource.

type DataSource interface {
  GetComments(PostUri string) (CommentList, error)
  Close() error
}

func ListComments(rw http.ResponseWriter, req *http.Request) {
  postUri, err := stripPrefix("list/", req.URL)
  if err != nil { ... }
  var ds DataSource
  ds, err = mariadb.Open(*dbConnectionString)
  if err != nil { ... }
  comments, err := ds.GetComments(postUri)
  if err != nil { ... }
  output, err := json.Marshal(comments)
  if err != nil { ... }
  rw.Header().Add("Content-Type", "text/json")
  rw.Write(output)
}

Xa que temos unha interface de alto nivel, podemos tamén facer unha implementación que só use a memoria para poder usala nos tests unitarios. A cuestión é como podemos usar esa implementación.

Por exemplo, poderiamos ter un argumento da liña de ordes que selecciona se usamos unha implementación ou outra, pero sería bastante pesado, especialmente se temos que poñer ese “if” en todas as partes nas que precisamos dun obxecto DataSource.

// Hai que poñer isto no código de todos os métodos da API.
if (*useRealDb) {
  ds, err = mariadb.Open(*dbConnectionString)
} else {
  ds, err = dummydb.Create()
}

Agh. Arrepiante. Debería haber unha solución mellor. Por sorte haina, e chámase “inxección de dependencias”.

A inxección de dependencias é unha idea moi simple: o teu código non crea os recursos que usa, senón que lle chegan creados de fóra. Desta maneira, se hai que facer cambios no recurso, só hai que mudar o sitio no que se crea o recurso, non todos os sitios nos que se usa.

Podo inxectar o DataSource no meu programa creando a instancia na función main e despois pasándolla ao servizo para que a use.

type commentsService struct {
  ds     data.DataSource
}

func main() {
  flag.Parse()
  ds, err := mariadb.Open(*dbConnectionString)
  if err != nil { ... }
  service := commentsService{
    ds:     ds,
  }
  mux := http.NewServeMux()
  mux.HandleFunc(prefix+"list/", service.ListComments)
  mux.Handle("/", http.FileServer(http.Dir(*contentRoot)))
  server := &http.Server{Addr: *serverAddress, Handler: mux}
  log.Printf("Now listening on %s", *serverAddress)
  log.Fatal(server.ListenAndServe())
}

func (s *commentsService) ListComments(rw http.ResponseWriter,
                                       req *http.Request) {
  comments, err := s.ds.GetComments(postUri)
  if err != nil { ... }
  // etc
}

Xa falarei máis de inxección de dependencias cando fale dos tests unitarios, que esta Folla xa se fixo longa de máis. Vémonos na próxima!