La première question pour les développeurs qui commencent tout juste à utiliser Go ressemble souvent à ceci: "Quel framework utiliser pour résoudre le problème X". Bien qu'il s'agisse d'une question parfaitement normale lorsqu'elle est posée avec des applications Web et des serveurs écrits dans de nombreuses autres langues, dans le cas de Go, il y a de nombreuses subtilités à prendre en compte pour répondre à cette question. Il existe de solides arguments pour et contre l'utilisation de frameworks dans les projets Go. Tout en travaillant sur des articles de cette série, je vois mon objectif comme une étude objective et polyvalente de cette question.
Une tâche
Pour commencer, je veux dire qu'ici je pars de l'hypothèse que le lecteur est familier avec le concept de "serveur REST". Si vous avez besoin d'un rappel, jetez un œil à ce bon matériel (mais il existe de nombreux autres articles similaires). À partir de maintenant, je suppose que vous comprendrez ce que je veux dire lorsque j'utilise les termes «chemin», «en-tête HTTP», «code de réponse», etc.
Dans notre cas, le serveur est un simple système backend pour une application qui implémente des fonctionnalités de gestion des tâches (comme Google Keep, Todoist, etc.). Le serveur fournit l'API REST suivante aux clients:
POST /task/ : ID GET /task/<taskid> : ID GET /task/ : DELETE /task/<taskid> : ID GET /tag/<tagname> : GET /due/<yy>/<mm>/<dd> : ,
Notez que cette API a été créée spécifiquement pour notre exemple. Dans les prochaines tranches de cette série, nous parlerons d'une approche plus structurée et standardisée de la conception d'API.
Notre serveur prend en charge les requêtes GET, POST et DELETE, dont certaines peuvent utiliser plusieurs chemins. Ce qui est indiqué entre crochets (
<...>
) dans la description de l'API indique les paramètres que le client fournit au serveur dans le cadre d'une requête. Par exemple, la demande est
GET /task/42
dirigée pour recevoir une tâche du serveur avec
ID
42
.
ID
Sont des identifiants de tâches uniques.
Les données sont encodées au format JSON. Lors de l'exécution d'une requête
POST /task/
le client envoie une représentation JSON de la tâche à créer au serveur. Et, de même, les réponses à ces demandes, dont la description dit qu'elles «renvoient» quelque chose, contiennent des données JSON. En particulier, ils sont placés dans le corps des réponses HTTP.
Le code
Ensuite, nous traiterons de l'écriture du code serveur dans Go étape par étape. La version complète peut être trouvée ici . Il s'agit d'un module Go autonome qui n'utilise pas de dépendances. Après avoir cloné ou copié le répertoire du projet sur l'ordinateur, le serveur peut immédiatement, sans rien installer de plus, exécuter:
$ SERVERPORT=4112 go run .
Veuillez noter que
SERVERPORT
vous pouvez utiliser n'importe quel port qui écoutera sur le serveur local en attendant les connexions. Une fois le serveur démarré, à l'aide d'une fenêtre de terminal distincte, vous pouvez l'utiliser à l'aide, par exemple, d'un utilitaire
curl
. Vous pouvez également interagir avec lui en utilisant d'autres programmes similaires. Des exemples de commandes utilisées pour envoyer des requêtes au serveur peuvent être trouvés dans ce script . Le répertoire contenant ce script contient des outils pour les tests de serveur automatisés.
Modèle
Commençons par discuter du modèle (ou «couche de données») de notre serveur. Vous pouvez le trouver dans le package
taskstore
(
internal/taskstore
dans le répertoire du projet). Il s'agit d'une simple abstraction représentant une base de données qui stocke des tâches. Voici son 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
Voici une déclaration de type
Task
:
type Task struct {
Id int `json:"id"`
Text string `json:"text"`
Tags []string `json:"tags"`
Due time.Time `json:"due"`
}
Le package
taskstore
implémente cette API à l'aide d'un dictionnaire simple
map[int]Task
et stocke les données en mémoire. Mais il n'est pas difficile d'imaginer une implémentation de cette API basée sur une base de données. Dans une application réelle
TaskStore
, ce sera très probablement une interface qui peut être implémentée par différents backends. Mais pour notre exemple simple, cette API suffit. Si vous voulez vous entraîner, implémentez en
TaskStore
utilisant quelque chose comme MongoDB.
Préparation du serveur au travail
La fonction de
main
notre serveur est assez simple:
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))
}
Prenons un peu de temps pour l'équipe
NewTaskServer
, puis nous parlerons du routeur et des gestionnaires de chemin.
NewTaskServer
Est un constructeur pour notre serveur, de type
taskServer
. Le serveur inclut
TaskStore
ce qui est sécurisé en termes d' accès simultané aux données .
type taskServer struct {
store *taskstore.TaskStore
}
func NewTaskServer() *taskServer {
store := taskstore.New()
return &taskServer{store: store}
}
Routage et gestionnaires de chemin
Revenons maintenant au routage. Cela utilise le multiplexeur HTTP standard inclus dans le package
net/http
:
mux.HandleFunc("/task/", server.taskHandler)
mux.HandleFunc("/tag/", server.tagHandler)
mux.HandleFunc("/due/", server.dueHandler)
Le multiplexeur standard a des capacités plutôt modestes. C'est à la fois sa force et sa faiblesse. Son point fort est qu'il est très facile de s'en occuper, car il n'y a rien de difficile dans son travail. Et la faiblesse du multiplexeur standard est que parfois son utilisation rend la résolution du problème de la mise en correspondance des requêtes avec les chemins disponibles dans le système assez fastidieuse. Ce qui, selon la logique des choses, serait bien d'être situé au même endroit, il faut le placer à différents endroits. Nous en parlerons plus en détail sous peu.
Étant donné que le multiplexeur standard ne prend en charge que la correspondance exacte des demandes aux préfixes de chemin, nous sommes pratiquement obligés de nous fier uniquement aux chemins racine au niveau supérieur et de déléguer la tâche de trouver le chemin exact aux gestionnaires de chemin.
Examinons le gestionnaire de chemin
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
}
Nous commençons par vérifier une correspondance exacte du chemin avec
/task/
(ce qui signifie qu'il n'y en a pas à la fin
<taskid>
). Ici, nous devons comprendre quelle méthode HTTP est utilisée et appeler la méthode serveur correspondante. La plupart des gestionnaires de chemins sont des wrappers d'API assez simples
TaskStore
. Regardons l'un de ces gestionnaires:
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)
}
Il résout deux tâches principales:
- Reçoit les données du modèle (
TaskStore
). - Génère une réponse HTTP pour le client.
Ces deux tâches sont assez simples et directes, mais si vous examinez le code d'autres gestionnaires de chemin, vous pouvez remarquer que la deuxième tâche a tendance à se répéter - elle consiste à marshaler les données JSON, à préparer l'en-tête de réponse HTTP correct et à effectuer d'autres actions similaires. ... Nous soulèverons cette question plus tard.
Revenons maintenant à
taskHandler
. Jusqu'à présent, nous avons seulement vu comment il gère les demandes qui ont une correspondance de chemin exacte
/task/
. Et le chemin
/task/<taskid>
? C'est là qu'intervient la deuxième partie de la fonction:
} 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
}
}
Lorsque la requête ne correspond pas exactement au chemin
/task/
, nous nous attendons à ce que le
ID
problème numérique suive la barre oblique . Le code ci-dessus analyse celui-ci
ID
et appelle le gestionnaire approprié (basé sur la méthode de requête HTTP).
Le reste du code est plus ou moins similaire à celui que nous avons déjà couvert, il devrait être facile à comprendre.
Amélioration du serveur
Maintenant que nous avons une version fonctionnelle de base du serveur, il est temps de réfléchir aux problèmes éventuels qui pourraient survenir avec celui-ci et comment l'améliorer.
L'une des constructions de programmation que nous utilisons et qui doit évidemment être améliorée, et dont nous avons déjà parlé, est le code répétitif pour préparer les données JSON lors de la génération de réponses HTTP. J'ai créé une version distincte du serveur, stdlib-factorjson , qui résout ce problème. J'ai séparé cette implémentation de serveur dans un dossier séparé afin de faciliter sa comparaison avec le code du serveur d'origine et d'analyser les changements. La principale innovation de ce code est représentée par la fonction suivante:
// 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)
}
En utilisant cette fonction, nous pouvons réécrire le code de tous les gestionnaires de chemin, en le raccourcissant. Par exemple, voici à quoi ressemble le code maintenant
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)
}
Une amélioration plus fondamentale serait de rendre le code de mappage requête-chemin plus propre et, si possible, de collecter ce code en un seul endroit. Alors que l'approche actuelle pour faire correspondre les requêtes et les chemins facilite le débogage, le code qui le sous-tend est difficile à comprendre à première vue car il est dispersé sur plusieurs fonctions. Par exemple, supposons que nous essayions de comprendre comment une requête
DELETE
dirigée vers un fichier
/task/<taskid>
. Pour le faire, suivez ces étapes:
- - —
main
,/task/
taskHandler
. - ,
taskHandler
,else
, ,/task/
.<taskid>
. - —
if
, , , , ,DELETE
deleteTaskHandler
.
Vous pouvez mettre tout ce code en un seul endroit. Ce sera beaucoup plus facile et plus pratique de travailler avec. C'est exactement ce que visent les routeurs HTTP tiers. Nous en parlerons dans la deuxième partie de cette série d'articles.
❒ Ceci est la première partie d'une série sur le développement de serveurs Go. Vous pouvez consulter la liste des articles au début de l' original de ce matériel.