Development of REST servers in Go. Part 1: the standard library

This is the first in a series of articles on developing REST servers in Go. In these articles, I plan to describe a simple REST server implementation using several different approaches. As a result, these approaches can be compared with each other, it will be possible to understand their relative advantages over each other.



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:



  1. Receives data from the model ( TaskStore



    ).
  2. 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:



  1. - β€” main



    , /task/



    taskHandler



    .
  2. , taskHandler



    , else



    , , /task/



    . <taskid>



    .
  3. β€” 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.








All Articles