Entwicklung von REST-Servern in Go. Teil 1: Die Standardbibliothek

Dies ist der erste einer Reihe von Artikeln zur Entwicklung von REST-Servern in Go. In diesen Artikeln möchte ich eine einfache REST-Server-Implementierung mit verschiedenen Ansätzen beschreiben. Infolgedessen können diese Ansätze miteinander verglichen werden, und es wird möglich sein, ihre relativen Vorteile gegenüber einander zu verstehen.



Die erste Frage für Entwickler, die gerade erst anfangen, Go zu verwenden, sieht oft so aus: "Welches Framework sollte zur Lösung von Problem X verwendet werden?". Während dies eine ganz normale Frage ist, wenn Webanwendungen und Server in vielen anderen Sprachen geschrieben werden, sind im Fall von Go bei der Beantwortung dieser Frage viele Feinheiten zu berücksichtigen. Es gibt starke Argumente für und gegen die Verwendung von Frameworks in Go-Projekten. Während ich an Artikeln aus dieser Reihe arbeite, sehe ich mein Ziel als eine objektive, vielseitige Untersuchung dieses Themas.



Eine Aufgabe



Zunächst möchte ich sagen, dass ich hier von der Annahme ausgehe, dass der Leser mit dem Konzept des "REST-Servers" vertraut ist. Wenn Sie eine Auffrischung benötigen, schauen Sie sich dieses gute Material an (aber es gibt viele andere ähnliche Artikel). Von nun an gehe ich davon aus, dass Sie verstehen werden, was ich meine, wenn ich die Begriffe "Pfad", "HTTP-Header", "Antwortcode" und dergleichen verwende.



In unserem Fall ist der Server ein einfaches Backend-System für eine Anwendung, die Aufgabenverwaltungsfunktionen implementiert (wie Google Keep, Todoist und dergleichen). Der Server stellt Clients die folgende REST-API zur Verfügung:



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

      
      





Beachten Sie, dass diese API speziell für unser Beispiel erstellt wurde. In den nächsten Abschnitten dieser Reihe werden wir über einen strukturierteren und standardisierteren Ansatz für das API-Design sprechen.



Unser Server unterstützt GET-, POST- und DELETE-Anforderungen, von denen einige mehrere Pfade verwenden können. Was in der API-Beschreibung in spitzen Klammern ( <...>



) angezeigt wird, bezeichnet Parameter, die der Client dem Server als Teil einer Anforderung zur Verfügung stellt. Beispielsweise wird die Anforderung GET /task/42



angewiesen, eine Aufgabe vom Server mit zu empfangen ID



42



. ID



Sind eindeutige Aufgabenkennungen.



Die Daten werden im JSON-Format codiert. Bei der Ausführung einer Anfrage POST /task/



Der Client sendet eine JSON-Darstellung der zu erstellenden Aufgabe an den Server. Ebenso enthalten die Antworten auf diese Anfragen, deren Beschreibung besagt, dass sie etwas "zurückgeben", JSON-Daten. Insbesondere werden sie in den Hauptteil der HTTP-Antworten eingefügt.



Der Code



Als nächstes werden wir uns Schritt für Schritt mit dem Schreiben des Servercodes in Go befassen. Die Vollversion finden Sie hier . Es ist ein in sich geschlossenes Go-Modul, das keine Abhängigkeiten verwendet. Nach dem Klonen oder Kopieren des Projektverzeichnisses auf den Computer kann der Server sofort ohne zusätzliche Installation Folgendes ausführen:



$ SERVERPORT=4112 go run .

      
      





Bitte beachten Sie, dass SERVERPORT



Sie jeden Port verwenden können, der den lokalen Server überwacht, während Sie auf Verbindungen warten. Nachdem der Server über ein separates Terminalfenster gestartet wurde, können Sie beispielsweise mit einem Dienstprogramm damit arbeiten curl



. Sie können auch mit anderen ähnlichen Programmen damit interagieren. Beispiele für Befehle zum Senden von Anforderungen an den Server finden Sie in diesem Skript . Das Verzeichnis, das dieses Skript enthält, enthält Tools für automatisierte Servertests.



Modell



Beginnen wir mit der Erörterung des Modells (oder der "Datenschicht") für unseren Server. Sie finden es im Paket taskstore



( internal/taskstore



im Projektverzeichnis). Dies ist eine einfache Abstraktion, die eine Datenbank darstellt, in der Aufgaben gespeichert sind. Hier ist seine 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

      
      





Hier ist eine Typdeklaration Task



:



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

      
      





Das Paket taskstore



implementiert diese API mithilfe eines einfachen Wörterbuchs map[int]Task



und speichert die Daten im Speicher. Eine datenbankgesteuerte Implementierung dieser API ist jedoch nicht schwer vorstellbar. In einer realen Anwendung TaskStore



handelt es sich höchstwahrscheinlich um eine Schnittstelle, die von verschiedenen Backends implementiert werden kann. Für unser einfaches Beispiel reicht diese API jedoch aus. Wenn Sie üben möchten, implementieren Sie TaskStore



etwas wie MongoDB.



Server für die Arbeit vorbereiten



Die Funktion main



unseres Servers ist ganz einfach:



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))
}

      
      





Nehmen NewTaskServer



wir uns etwas Zeit für das Team und sprechen dann über den Router und die Pfadhandler.



NewTaskServer



Ist ein Konstruktor für unseren Server vom Typ taskServer



. Der Server enthält TaskStore



Informationen, die hinsichtlich des gleichzeitigen Datenzugriffs sicher sind .



type taskServer struct {
  store *taskstore.TaskStore
}

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

      
      





Routing- und Pfadhandler



Kommen wir nun zum Routing zurück. Dies verwendet den im Paket enthaltenen Standard-HTTP-Multiplexer net/http



:



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

      
      





Der Standard-Multiplexer hat eher bescheidene Fähigkeiten. Dies ist sowohl seine Stärke als auch seine Schwäche. Seine Stärke ist, dass es sehr einfach ist, damit umzugehen, da es nichts Schwieriges in seiner Arbeit gibt. Und die Schwäche des Standard-Multiplexers besteht darin, dass seine Lösung manchmal das Lösen des Problems des Abgleichs von Anforderungen mit den im System verfügbaren Pfaden ziemlich mühsam macht. Was nach der Logik der Dinge schön wäre, an einem Ort zu sein, muss man an verschiedenen Orten platzieren. Wir werden in Kürze mehr darüber sprechen.



Da der Standardmultiplexer nur die exakte Zuordnung von Anforderungen zu Pfadpräfixen unterstützt, sind wir praktisch gezwungen, uns nur auf die Stammpfade auf der obersten Ebene zu verlassen und die Aufgabe, den genauen Pfad zu finden, an die Pfadhandler zu delegieren.



Lassen Sie uns den Pfadhandler untersuchen 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
    }

      
      





Wir beginnen mit der Überprüfung der genauen Übereinstimmung des Pfads mit /task/



(was bedeutet, dass es nicht am Ende ist <taskid>



). Hier müssen wir verstehen, welche HTTP-Methode verwendet wird, und die entsprechende Servermethode aufrufen. Die meisten Pfadhandler sind ziemlich einfache API-Wrapper TaskStore



. Schauen wir uns einen dieser Handler an:



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)
}

      
      





Es löst zwei Hauptaufgaben:



  1. Empfängt Daten von model ( TaskStore



    ).
  2. Generiert eine HTTP-Antwort für den Client.


Beide Aufgaben sind recht einfach und unkompliziert. Wenn Sie jedoch den Code anderer Pfadhandler untersuchen, können Sie feststellen, dass sich die zweite Aufgabe in der Regel wiederholt. Sie besteht darin, JSON-Daten zu sammeln, den richtigen HTTP-Antwortheader vorzubereiten und in andere ähnliche Aktionen ausführen. ... Wir werden dieses Problem später noch einmal ansprechen.



Gehen wir jetzt zurück zu taskHandler



. Bisher haben wir nur gesehen, wie Anforderungen mit einer genauen Pfadübereinstimmung behandelt werden /task/



. Was ist mit dem Weg /task/<taskid>



? Hier kommt der zweite Teil der Funktion ins Spiel:



} 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
  }
}

      
      





Wenn die Abfrage nicht genau mit dem Pfad übereinstimmt /task/



, erwarten wir, dass das numerische ID



Problem dem Schrägstrich folgt . Der obige Code analysiert diesen ID



und ruft den entsprechenden Handler auf (basierend auf der HTTP-Anforderungsmethode).



Der Rest des Codes ähnelt mehr oder weniger dem, den wir bereits behandelt haben. Er sollte leicht zu verstehen sein.



Serververbesserung



Nachdem wir nun eine grundlegende funktionierende Version des Servers haben, ist es an der Zeit, über mögliche Probleme nachzudenken und diese zu verbessern.



Eines der von uns verwendeten Programmierkonstrukte, das offensichtlich verbessert werden muss und über das wir bereits gesprochen haben, ist der sich wiederholende Code zum Aufbereiten von JSON-Daten beim Generieren von HTTP-Antworten. Ich habe eine separate Version des Servers erstellt, stdlib-factorjson , mit der dieses Problem behoben wird. Ich habe diese Serverimplementierung in einen separaten Ordner unterteilt, um den Vergleich mit dem ursprünglichen Servercode zu vereinfachen und die Änderungen zu analysieren. Die Hauptinnovation dieses Codes wird durch die folgende Funktion dargestellt:



// 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)
}

      
      





Mit dieser Funktion können wir den Code aller Pfadhandler neu schreiben und verkürzen. So sieht der Code jetzt beispielsweise aus 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)
}

      
      





Eine grundlegendere Verbesserung wäre, den Code für die Zuordnung von Anforderungen zu Pfaden sauberer zu gestalten und diesen Code nach Möglichkeit an einem Ort zu sammeln. Während der derzeitige Ansatz zum Abgleichen von Anforderungen und Pfaden das Debuggen erleichtert, ist der dahinter stehende Code auf den ersten Blick schwer zu verstehen, da er über mehrere Funktionen verteilt ist. Angenommen, wir versuchen herauszufinden, wie eine Anforderung DELETE



an a gerichtet ist /task/<taskid>



. Gehen Sie dazu folgendermaßen vor:



  1. - — main



    , /task/



    taskHandler



    .
  2. , taskHandler



    , else



    , , /task/



    . <taskid>



    .
  3. if



    , , , , , DELETE



    deleteTaskHandler



    .


Sie können diesen gesamten Code an einem Ort ablegen. Es wird viel einfacher und bequemer sein, damit zu arbeiten. Genau darauf zielen HTTP-Router von Drittanbietern ab. Wir werden im zweiten Teil dieser Artikelserie darüber sprechen.



Dies ist der erste Teil einer Reihe zur Entwicklung von Go-Servern. Sie können die Artikelliste am Anfang des Originals dieses Materials anzeigen .








All Articles