Life cycle of HTTP request in Go service

Keywords: Go http

Go language is a common and very suitable tool for writing HTTP services. This blog post discusses the routing of a typical HTTP request through a go service, involving routing, middleware and related issues such as concurrency.

In order to have specific code for reference, let's start with this simple service code (from   Go by Example: HTTP Servers)

package main

import (
    "fmt"
    "net/http"
)

func hello(w http.ResponseWriter, req *http.Request) {
    fmt.Fprintf(w, "hello\n")
}

func headers(w http.ResponseWriter, req *http.Request) {
    for name, headers := range req.Header {
        for _, h := range headers {
            fmt.Fprintf(w, "%v: %v\n", name, h)
        }
    }
}

func main() {
    http.HandleFunc("/hello", hello)
    http.HandleFunc("/headers", headers)

    http.ListenAndServe(":8090", nil)
}

We'll check   http.ListenAndServe   Function to start tracking the life cycle of an HTTP request in this service:

func ListenAndServe(addr string, handler Handler) error

This figure shows the brief process that occurs during the call:

This is a highly "inline" version of the actual sequence of function and method calls, but Original code It's not hard to understand.

The main process is what you expect: ListenAndServe   Listen to the TCP port of the given address, and then cycle to accept new connections. For each new connection, it will schedule a goroutine to process the connection (described in detail later). Processing the connection involves such a cycle:

  • Parsing HTTP requests from connections; generating   http.Request
  • Put this   http.Request   Passed to user-defined handler

handler is an implementation   http.Handler   Any instance of the interface:

type Handler interface {
        ServeHTTP(ResponseWriter, *Request)
}

Default handler

In our example code, ListenAndServe   Used when called   nil   As the second parameter, this location should have used a user-defined handler. What's going on?

Our diagram simplifies some details; in fact, when the HTTP package processes a request, it does not directly call the user's handler, but uses the adapter:

type serverHandler struct {
    srv *Server
}

func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {
    handler := sh.srv.Handler
    if handler == nil {
        handler = DefaultServeMux
    }
    if req.RequestURI == "*" && req.Method == "OPTIONS" {
        handler = globalOptionsHandler{}
    }
    handler.ServeHTTP(rw, req)
}

Note the highlighted part (if handler == nil...), if   handler == nil, then   http.DefaultServeMux   Used as a handler. This is the default server mux, http   One contained in the package   http.ServeMux   By the way, when our sample code uses   http.HandleFunc   When registering the handler function, these handlers will be registered on the same default mux.

We can rewrite our sample code as follows, no longer using the default mux. Just modify   main   Function, so it's not shown here   hello   and   headers   handler function. We can see it here Complete code [^ 1]:

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/hello", hello)
    mux.HandleFunc("/headers", headers)

    http.ListenAndServe(":8090", mux)
}

One   ServeMux   Just a   Handler

When you look at many examples of Go service, it's easy to give people an impression   ListenAndServe   The function "needs a mux" as an argument, but this is inaccurate. As we saw before, listenandserve   What the function needs is an implementation   http.Handler   Interface value. We can write the following services without any mux:

type PoliteServer struct {
}

func (ms *PoliteServer) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    fmt.Fprintf(w, "Welcome! Thanks for visiting!\n")
}

func main() {
    ps := &PoliteServer{}
    log.Fatal(http.ListenAndServe(":8090", ps))
}

Because there is no routing logic here; all arrivals   PoliteServer   of   Serve HTTP   Method will reply with the same message. Try using different paths and methods   curl  - ing this service; the return must be consistent.

We can use   http.HandlerFunc   To further simplify our polite service:

func politeGreeting(w http.ResponseWriter, req *http.Request) {
    fmt.Fprintf(w, "Welcome! Thanks for visiting!\n")
}

func main() {
    log.Fatal(http.ListenAndServe(":8090", http.HandlerFunc(politeGreeting)))
}

HandlerFunc   It's such a place   http   Smart adapter in package:

// The HandlerFunc type is an adapter to allow the use of
// ordinary functions as HTTP handlers. If f is a function
// with the appropriate signature, HandlerFunc(f) is a
// Handler that calls f.
type HandlerFunc func(ResponseWriter, *Request)

// ServeHTTP calls f(w, r).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}

If you notice in the first example of this blog post   http.HandleFunc[^2], which has   HandlerFunc   Signed functions use the same adapter.

Like   PoliteServer   Same as http.servermux   It's an implementation   http.Handler   The type of interface. You can read it carefully if you like Complete code ; this is an outline:

  • ServeMux   A sorted by length is maintained   {pattern, handler}   Slice of.
  • Handle   or   HandleFunc   Add a new handler to the slice.
  • ServeHTTP:

    • (by looking up the slice of the sorted handler pair) find the corresponding handler for the requested path
    • Calling handler   ServeHTTP   method

Therefore, mux can be regarded as a forwarding handler; this pattern is very common in HTTP service development, which is middleware.

http.Handler   middleware

Because middleware means different things in different contexts, different languages and different frameworks, it is difficult to be accurately defined.

Let's go back to the flowchart at the beginning of this blog post and simplify and hide it a little   http   Details of package execution:

Now, when we add middleware, the flow chart looks like this:

In Go language, middleware is just another HTTP handler, which wraps another handler. Middleware handler calls   ListenAndServe   Is registered; when called, it can perform any preprocessing, call its own package handler, and then perform any post-processing.

We have seen an example of middleware before——   Http. Servermux; in this example, preprocessing is to select the correct user handler to call based on the requested path. There is no post-processing.

Another specific example is to go back to our polite service and add some basic logging middleware. This middleware records the specific log of each request, including the execution time:

type LoggingMiddleware struct {
    handler http.Handler
}

func (lm *LoggingMiddleware) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    start := time.Now()
    lm.handler.ServeHTTP(w, req)
    log.Printf("%s %s %s", req.Method, req.RequestURI, time.Since(start))
}

type PoliteServer struct {
}

func (ms *PoliteServer) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    fmt.Fprintf(w, "Welcome! Thanks for visiting!\n")
}

func main() {
    ps := &PoliteServer{}
    lm := &LoggingMiddleware{handler: ps}
    log.Fatal(http.ListenAndServe(":8090", lm))
}

be careful   LoggingMiddleware   Itself is a   http.Handler, which holds a user handler as a field   ListenAndServe   Call its   ServeHTTP   Method, which does the following:

  1. Preprocessing: record a timestamp before the user's handler executes
  2. Call the user handler with the request and return writer
  3. Post processing: record the detailed request log, including time-consuming

The biggest advantage of middleware is that it can be combined. The "user handler" wrapped by middleware can also be another middleware, and so on. This is a mutual wrapping   http.Handler   Chain. In fact, this is a common pattern in Go. Let's take a look at the classic usage of Go middleware. Our log poly service uses a more recognizable Go middleware implementation this time:

func politeGreeting(w http.ResponseWriter, req *http.Request) {
    fmt.Fprintf(w, "Welcome! Thanks for visiting!\n")
}

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, req)
        log.Printf("%s %s %s", req.Method, req.RequestURI, time.Since(start))
    })
}

func main() {
    lm := loggingMiddleware(http.HandlerFunc(politeGreeting))
    log.Fatal(http.ListenAndServe(":8090", lm))
}

Instead of creating a structure with methods, loggingMiddleware   utilize   http.HandlerFunc   And closures make the code more concise and retain the same function. More importantly, this example shows the de facto standard signature of middleware: a function passes in a function   http.Handler, and sometimes other states, and then return a different   http.Handler. The returned handler should now replace the handler passed into the middleware, and then it will "magically" perform its original functions and be packaged with the functions of the middleware.

For example, the standard library contains the following middleware:

func TimeoutHandler(h Handler, dt time.Duration, msg string) Handler

If our code has   http.Handler, wrap it like this:

handler = http.TimeoutHandler(handler, 2 * time.Second, "timed out")

A new version of handler is created, which has a built-in 2-second timeout mechanism.

The combination of middleware can be shown as follows:

handler = http.TimeoutHandler(handler, 2 * time.Second, "timed out")
handler = loggingMiddleware(handler)

After two lines of code, handler   It will have timeout and logging functions. You may notice that middleware with long links is cumbersome to write; Go has many popular packages that can solve this problem, but this is beyond the scope of this article.

By the way, although   http   The package uses middleware internally to meet its own needs; For details, see the previous post on   serverHandler   Examples of adapters. However, it provides a clear way to handle the case where the user handler is nil with the default behavior (pass the request into the default mux).

I hope this can let you understand why middleware is a very attractive CAD. We can focus on our "business logic" handler. Although it is completely orthogonal, we use general middleware to improve our handler in many aspects. In other articles, it will be discussed comprehensively.

Concurrency and panic processing

To conclude our exploration of HTTP requests in the Go HTTP service, let's introduce two other topics: concurrency and panic processing.

The first is concurrency. As mentioned earlier, each connection is made by   http.Server.Serve   Process in a new goroutine.

This is Go's   net/http   It takes advantage of the excellent concurrency performance of Go and uses the lightweight goroutine to maintain a very simple concurrency model for HTTP handler. When a handler is blocked (for example, reading a database), there is no need to worry about slowing down other handlers. However, you need to be careful when writing a handler that has shared data. Refer to for details Previous articles.

Finally, panic processing. An HTTP service is usually a long-running background process. If something bad happens in the request handler provided by the user, for example, some bug s that lead to runtime panic. It will cause the whole service to crash, which is not a good thing. In order to avoid such a tragedy, you may consider in your service   main   Add to function   recover, but it is useless for the following reasons:

  1. When control is returned to   main   Function, ListenAndServe   It has been executed without providing any services.
  2. Since each connection is processed in a separate goroutine, when the panic is sent in the handler, it will not even affect the   main   Function, but it will cause the corresponding process to crash.

To provide some help, net/http   Package (in)   conn.serve   Method) has a built-in recovery for each service goroutine. We can see its function through simple examples:

func hello(w http.ResponseWriter, req *http.Request) {
    fmt.Fprintf(w, "hello\n")
}

func doPanic(w http.ResponseWriter, req *http.Request) {
    panic("oops")
}

func main() {
    http.HandleFunc("/hello", hello)
    http.HandleFunc("/panic", doPanic)

    http.ListenAndServe(":8090", nil)
}

If we run this service, and   curl  / panic   Path, we can see:

$ curl localhost:8090/panic
curl: (52) Empty reply from server

And the service will print such information in its own log:

2021/02/16 09:44:31 http: panic serving 127.0.0.1:52908: oops
goroutine 8 [running]:
net/http.(*conn).serve.func1(0xc00010cbe0)
    /usr/local/go/src/net/http/server.go:1801 +0x147
panic(0x654840, 0x6f0b80)
    /usr/local/go/src/runtime/panic.go:975 +0x47a
main.doPanic(0x6fa060, 0xc0001401c0, 0xc000164200)
[... rest of stack dump here ...]

However, the service will remain running and we can continue to access it!

Although this built-in protection mechanism is better than service crash, many developers have found its limitations. This protection mechanism only closes the connection and outputs errors in the log; In general, it is much more useful to return some kind of error response (such as code 500 -- built-in error) and additional details to the client.

After reading this blog post, it should be easy to write middleware to realize this function. Use it as an exercise! I will introduce this use case in a later blog post.

[^ 1]: This version has good reasons to prefer this version over the version using the default mux. The default mux has certain security risks; As a global instance, it can be modified by any package introduced in your project. A malicious package can also use it for evil purposes. [^ 2]: Note: http.HandleFunc   and   http.HandlerFunc   Are different entities with different and interrelated roles.

Posted by rajavel on Wed, 27 Oct 2021 19:02:34 -0700