The first question for developers who are just starting to use Go often looks like this: "What framework should be used to solve problem X". While this is a perfectly normal question when asked with web applications and servers written in many other languages ββin mind, in the case of Go, there are many subtleties to consider when answering this question. There are strong arguments for and against the use of frameworks in Go projects. While working on articles from this series, I see my goal as an objective, versatile study of this issue.
A task
To begin with, I want to say that here I proceed from the assumption that the reader is familiar with the concept of "REST server". If you need a refresher, take a look at this good material (but there are many other similar articles). From now on I will assume that you will understand what I mean when I use the terms "path", "HTTP header", "response code" and the like.
In our case, the server is a simple backend system for an application that implements task management functionality (like Google Keep, Todoist, and the like). The server provides the following REST API to clients:
POST /task/ : ID GET /task/<taskid> : ID GET /task/ : DELETE /task/<taskid> : ID GET /tag/<tagname> : GET /due/<yy>/<mm>/<dd> : ,
Please note that this API was created specifically for our example. In the next installments of this series, we'll talk about a more structured and standardized approach to API design.
Our server supports GET, POST and DELETE requests, some of them with the ability to use multiple paths. What is shown in angle brackets (
<...>
) in the API description denotes parameters that the client provides to the server as part of a request. For example, the request is
GET /task/42
directed to receive a task from the server with
ID
42
.
ID
Are unique task identifiers.
The data is encoded in JSON format. When executing a request
POST /task/
the client sends a JSON representation of the task to be created to the server. And, similarly, the responses to those requests, the description of which says that they "return" something, contain JSON data. In particular, they are placed in the body of HTTP responses.
The code
Next, we will deal with the step-by-step writing of the server code in Go. The full version can be found here . It is a self-contained Go module that does not use dependencies. After cloning or copying the project directory to the computer, the server can immediately, without installing anything, run:
$ SERVERPORT=4112 go run .
Please note that
SERVERPORT
you can use any port that will listen on the local server while waiting for connections. After the server is started, using a separate terminal window, you can work with it using, for example, a utility
curl
. You can also interact with it using some other similar programs. Examples of commands used to send requests to the server can be found in this script . The directory containing this script contains tools for automated server testing.
Model
Let's start by discussing the model (or "data layer") for our server. You can find it in the package
taskstore
(
internal/taskstore
in the project directory). This is a simple abstraction representing a database that stores tasks. Here is its 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
Here is a type declaration
Task
:
type Task struct {
Id int `json:"id"`
Text string `json:"text"`
Tags []string `json:"tags"`
Due time.Time `json:"due"`
}
The package
taskstore
implements this API using a simple dictionary
map[int]Task
and stores the data in memory. But it's not hard to imagine a database-driven implementation of this API. In a real application
TaskStore
, it will most likely be an interface that can be implemented by different backends. But for our simple example, this API is enough. If you want to practice, implement
TaskStore
using something like MongoDB.
Preparing the server for work
The function of
main
our server is quite 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))
}
Let's take some time for the team
NewTaskServer
, and then we'll talk about the router and path handlers.
NewTaskServer
Is a constructor for our server, of type
taskServer
. The server includes
TaskStore
what is secure in terms of concurrent data access .
type taskServer struct {
store *taskstore.TaskStore
}
func NewTaskServer() *taskServer {
store := taskstore.New()
return &taskServer{store: store}
}
Routing and path handlers
Now let's get back to routing. This uses the standard HTTP multiplexer included in the package
net/http
:
mux.HandleFunc("/task/", server.taskHandler)
mux.HandleFunc("/tag/", server.tagHandler)
mux.HandleFunc("/due/", server.dueHandler)
The standard multiplexer has rather modest capabilities. This is both his strength and his weakness. Its strength lies in the fact that it is very easy to deal with it, since there is nothing difficult in its work. And the weakness of the standard multiplexer is that sometimes its use makes solving the problem of matching requests with the paths available in the system rather tedious. What, according to the logic of things, would be nice to be located in one place, you have to place in different places. We will talk about this in more detail shortly.
Since the standard multiplexer only supports exact matching of requests to path prefixes, we are practically forced to rely only on the root paths at the top level and delegate the task of finding the exact path to the path handlers.
Let's examine the path handler
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
}
We start off by checking for an exact match of the path with
/task/
(which means there isn't at the end
<taskid>
). Here we need to understand which HTTP method is being used and call the corresponding server method. Most of the path handlers are fairly simple API wrappers
TaskStore
. Let's look at one of these handlers:
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)
}
It solves two main tasks:
- Receives data from the model (
TaskStore
). - Generates an HTTP response for the client.
Both of these tasks are quite simple and straightforward, but if you examine the code of other path handlers, you can notice that the second task tends to repeat itself - it consists in marshaling JSON data, in preparing the correct HTTP response header, and in performing other similar actions. ... We will raise this issue again later.
Let's go back now to
taskHandler
. So far, we've only seen how it handles requests that have an exact path match
/task/
. What about the path
/task/<taskid>
? This is where the second part of the function comes in:
} 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
}
}
When the query does not match the path exactly
/task/
, we expect the numeric
ID
problem to follow the forward slash . The above code parses this one
ID
and calls the appropriate handler (based on the HTTP request method).
The rest of the code is more or less similar to the one we have already covered, it should be easy to understand.
Server improvement
Now that we have a basic working version of the server, it's time to think about the possible problems that might arise with it and how to improve it.
One of the programming constructs that we use that obviously needs improvement, and which we have already talked about, is the repetitive code for preparing JSON data when generating HTTP responses. I created a separate version of the server, stdlib-factorjson , which resolves this issue. I have separated this server implementation into a separate folder in order to make it easier to compare it with the original server code and analyze the changes. The main innovation of this code is represented by the following function:
// 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)
}
Using this function, we can rewrite the code of all path handlers, shortening it. For example, here's what the code looks like now
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)
}
A more fundamental improvement would be to make the request-to-path mapping code cleaner and, if possible, to collect this code in one place. While the current approach to matching requests and paths makes debugging easier, the code behind it is difficult to understand at first glance as it is scattered across multiple functions. For example, suppose we are trying to figure out how a request
DELETE
that is directed to a
/task/<taskid>
. To do this, follow these steps:
- - β
main
,/task/
taskHandler
. - ,
taskHandler
,else
, ,/task/
.<taskid>
. - β
if
, , , , ,DELETE
deleteTaskHandler
.
You can put all this code in one place. It will be much easier and more convenient to work with it. This is exactly what third-party HTTP routers are aimed at. We'll talk about them in the second part of this article series.
β This is the first part in a series on Go server development. You can view the list of articles at the beginning of the original of this material.