DEV Community

Kenta Takeuchi
Kenta Takeuchi

Posted on • Edited on • Originally published at bmf-tech.com

From Homemade HTTP Router to New ServeMux

This article is a translation of 自作HTTPルーターから新しいServeMuxへ.

Overview

Up until now, I have been using a homemade HTTP router called goblin in my application, but since the ServeMux functionality has been expanded in Go1.22, I have started using ServeMux. It became so.

In this article, we will summarize the functions and performance of ServeMux added in Go1.22, and think about selecting a Go HTTP router in the future.

ServeMux features added in Go1.22

I was looking into the new features of ServeMux when Go1.22rc was released, but I thought I'd look into it in more detail.

cf. ServeMux specifications changed in Go1.22rc

We will summarize the new features of ServeMux based on the following reference information.

Defining routing by HTTP method

By specifying a path that includes an HTTP method, it is now possible to define routing using an HTTP method. When using ServeMux, it is no longer necessary to write conditional branching for HTTP methods in the handler.



http.HandleFunc("GET /items", handleItems)


Enter fullscreen mode Exit fullscreen mode

There is a constant for the HTTP method (ex. http.MethodGet), so I thought it would be a good idea to use it, but this is probably to keep the signature of the existing method in consideration for backward compatibility. I think it might look something like this.

Or maybe it's just a matter of adapting it to the HTTP request format.

Defining wildcard routing

By specifying a path using wildcards ({pathVal}), it is now possible to define wildcard routing.



// GET matches /items/1, /items/foo, etc.
http.HandleFunc("GET /items/{id}", handleItems)


Enter fullscreen mode Exit fullscreen mode

The path pattern can be specified in the following format. Even with Go1.22 or earlier, you can also specify the host name. (I found out relatively recently...)



[METHOD][HOST]/[PATH]


Enter fullscreen mode Exit fullscreen mode

The value that matches the wildcard can be obtained by using http.Request's PathValue method.



func handleItems(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// id will contain the value that matches the wildcard
}


Enter fullscreen mode Exit fullscreen mode

It is also possible to define routing using multiple wildcards.



// GET matches /items/1, /items/1/2, /itema/1/2/3, etc.
http.HandleFunc("GET /items/{id...}", handleItems)


Enter fullscreen mode Exit fullscreen mode

If you want to match the exact path, use {$}. Please keep this in mind when you want to define a route (/).



http.HandleFunc("GET /{$}", handleIndex)


Enter fullscreen mode Exit fullscreen mode

As an aside, third-party routers sometimes use * as a wildcard in the pattern. Personally, I want to call it a path parameter because I'm drawn to that image...(It's not something I care about, though.)

Things to note with the new ServeMux

Defining patterns using HTTP methods

In many third-party HTTP routers, an HTTP method is often a method.



// ex.
mux.Get("/items", handleItems)


Enter fullscreen mode Exit fullscreen mode

With ServeMux, there may be a small problem that it is difficult to detect typos because HTTP methods are included in patterns. It would be nice if I could check it with a linter, but I don't know what to do. This might be a good topic for creating your own static analysis tool. I feel like something will be done soon.

Priority rules

When defining routing using wildcards, pay attention to priorities.

cf.

With ServeMux, you can also define the following routing patterns:



// If both match, the former takes precedence
/items/new
/items/{id}

// If both match, the latter takes precedence
/items/{id...}
/items/{id}/category/{name}


Enter fullscreen mode Exit fullscreen mode

In the case of such a pattern, you need to be careful about which pattern the expected request matches.

Some HTTP routers in the world do not allow duplication in this pattern, while others do, and ServeMux falls under the latter.

On the other hand, in the following cases where a conflict occurs, ServeMux detects the conflict and generates a panic.



// There are cases where both match, and cases where only one matches.
/items/{id}
/{category}/items

// if both match
/items/{id}
/items/{name}


Enter fullscreen mode Exit fullscreen mode

In the case of a conflict, I think it is less troublesome than a duplicate, since the error can be detected at an early stage (testing, server startup, etc.).

By the way, in the self-made goblin, the pattern registered first is given priority. (It's actually quite complicated because it's not designed properly...)

For more information about ServeMux's conflict detection, see the following article.

cf. rhumie.github.io - ServeMux conflict detection and performance

Even third-party HTTP routers take conflict detection into consideration; for example, httprouter either matches one pattern or it doesn't. It is designed to become.

Only explicit matches: With other routers, like http.ServeMux, a requested URL path could match multiple patterns. Therefore they have some awkward pattern priority rules, like longest match or first registered, first matched. By design of this router, a request can only match exactly one or no route. As a result, there are also no unintended matches, which makes it great for SEO and improves the user experience.

cf. https://github.com/julienschmidt/httprouter?tab=readme-ov-file#features

The priority specification for pattern matching in an HTTP router is an important point that affects the quality of an HTTP router, so it is reassuring that this is properly designed.

Backward compatibility

There are some cases where backward compatibility is not maintained between Go1.22 and Go1.21.

cf. pkg.go.dev - Compatibility

In that case, you can return to Go1.21 behavior by setting httpmuxgo121=1 in the GODEBUG environment variable.

Internal implementation code reading

Routing pattern registration process

Below is the definition of the ServeMux structure.



// see: https://cs.opensource.google/go/go/+/master:src/net/http/server.go;l=2439;drc=960fa9bf66139e535d89934f56ae20a0e679e203;bpv=1;bpt=1

type ServeMux struct {
mu sync.RWMutex
tree routing Node
index routingIndex
patterns []*pattern // TODO(jba): remove if possible
mux121 serveMux121 // used only when GODEBUG=httpmuxgo121=1
}


Enter fullscreen mode Exit fullscreen mode

A tree structure is generated so that the paths become nodes, which seems to be a common data structure for HTTP routers.

The index and patterns are data used to detect pattern conflicts.

Routing matching process

If you are not familiar with reading, it may be a good idea to read the code of an HTTP server as a prerequisite.

cf. Golang HTTP server code reading

Comparison of ServeMux and other HTTP routers

I compared ServeMux with other HTTP routers using a homemade benchmark marker.

The self-made bench marker uses a previously implemented one. Please refer to the link below for details.

Benchmark results are published at go-router-benchmark.

The above bench marker measures only the matching of pass patterns, and registration of pass patterns is not included in the measurement.

Benchmark results

Although there are some differences when compared to Echo, GIN, and httprouter, which seem to have good performance based on benchmark results, overall it seems to have above-average performance.

What was remarkable was that performance deteriorated as the number of path parameters increased.

I feel that higher-level HTTP routers are designed to suppress this deterioration.

I don't think there are many cases where you use multiple path parameters, so I don't think you need to worry too much about this point.

Comparison between goblin and ServeMux

Goblin wins for static routing test cases, but ServeMux wins for dynamic routing.

Goblin seems to be working hard to suppress the performance deterioration due to the increase in the number of path parameters.

Again, I don't think there are many cases where multiple path parameters are used, so I don't think the difference in performance is of much practical use.

About HTTP router performance

ServeMux (the implementer of it) seems to think about the performance of HTTP routers as follows.

Implementation is out of scope for this discussion/proposal. I think we'd be happy to have a more complex implementation if it could be demonstrated that the current one actually affects latency or CPU usage. For typical servers, that usually access some storage backend over the network, I'd guess the matching time is negligible. Happy to be proven wrong.

cf. Quoted from https://github.com/golang/go/discussions/60227#discussioncomment-5932822

Other related comments are also included.

cf. https://github.com/golang/go/issues/61410#issuecomment-1867191476
cf. https://github.com/golang/go/issues/61410#issuecomment-1867485864
cf. https://github.com/golang/go/issues/61410#issuecomment-1868615273

Third-party HTTP routers that feature performance as one of their characteristics often employ complex tree-structured algorithms (e.g. Radix Tree, which is optimized for memory efficiency).

When implementing ServeMux, the idea seems to be not to use complex data structures or algorithms, as they won't have a big impact on latency or CPU usage unless you use extremely bad data structures.

This is not a disproval, but gorilla/mux has comparatively poor benchmark results among popular (many stars) third-party HTTP routers. , used by many users.

Some comments mention the performance of not only path pattern matching but also path pattern registration.

Registration time is potentially more of an issue. With the precedence rules described here, checking a new pattern for conflicts seems to require looking at all existing patterns in the worst case. (Algorithm lovers, you are hereby nerd-sniped.) That means registering n patterns takes O(n2) time in the worst case. With the naive algorithm that loops through all existing patterns, that "worst case" is in fact every (successful) case: if there are no conflicts it will check every pattern against every other, for n(n-1)/2 checks. To see if this matters in practice, I collected all the methods from 260 Google Cloud APIs described by discovery docs, resulting in about 5000 patterns. In reality, no one server would serve all these patterns—more likely there are 260 separate servers—so I think this is a reasonable worst-case scenario. (Please correct me if I'm wrong.) Using naive conflict checking, it took about a second to register all the patterns—not too shabby for server startup, but not ideal. I then implemented a simple indexing scheme to weed out patterns that could not conflict, which reduced the time 20-fold, to 50 milliseconds. There are still sets of patterns that would trigger quadratic behavior, but I don't believe they would arise naturally; they would have to be carefully (maliciously?) constructed. And if you are being malicious, you are probably only hurting yourself: one writes patterns for one's own server, not the servers of others. If we do encounter real performance issues, we can index more aggressively.

cf. Quoted from https://github.com/golang/go/discussions/60227#discussioncomment-6204048

I had similar thoughts about the performance of HTTP routers, so I can agree with ServeMux's thoughts on performance.

goblin uses a data structure based on Trie Tree.

The reason why we did not choose Radix Tree, which is more memory efficient than Trie Tree, was because we felt that it would be complicated and difficult to understand and maintain, but the more complex the data structure, the better the performance benefits. Will I be able to enjoy it? Partly because I had doubts about that.

Go seems to pursue simplicity from the language philosophy, so I think the trend will be to optimize the current simplicity rather than adopting complex data structures and algorithms for ServeMux. I think so. (perhaps.)

Looking at the benchmark results, it seems like there are some tuning points, so I think the score will improve further in the future.

At that time, I would like to hold the 2nd Tenkaichi HTTP Router Fighting Tournament.

cf. Tenkaichi HTTP Router Fighting Association

Lessons learned from comparing ServeMux and a homemade HTTP router

What I noticed when looking at Go1.22's ServeMux implementation was that the routing algorithm was simple, yet had high performance.
Image description

I thought that as long as I didn't make a mistake in my selection, the performance would be guaranteed to a certain extent. (An algorithm amateur's view.)

routing_tree_test.go or pattern_test.go I thought the reason why the test case was clean was because the data structure was simple. .

goblin is quite tragic and is a point of concern.

The Trie Tree used by goblin is simple, but the way it is used is not simple, so there may be room for improvement.

Looking at the discussions and proposals, I wondered how much performance I expected from the data structure and algorithm selection. I once again felt that this perspective is important.

The more you pursue performance, the further you get from simplicity, so balance is probably important. (Basically, Uncle Balance.)

Personal opinion on Go router selection

When developing a new application from now on, I think the basic approach is to first consider ServeMux, and if there are any deficiencies, consider a third party.

On the other hand, in terms of whether you should consider migrating from the HTTP router used in your existing applications to ServeMux, I think there are the following points of view.

  • Do you want to focus as much on the standard library as possible, reducing dependencies on third parties?
    • If you were reluctantly using a third party because you couldn't use wildcards, you might want to actively consider switching.
  • How compatible is your HTTP router with net/http?
    • If you are using something that provides its own Handler definition or request parameter acquisition method, it may take some time to migrate.
  • Do you need features or performance that ServeMux doesn't have?
    • If you need middleware, regular expression routing, grouping, etc., it may make sense to continue using a third party.
  • Does the routing priority have its own logic?
    • There is no problem if it can be guaranteed by testing, but it may become one of the barriers when migrating.

I created a simple flowchart to decide whether to use ServeMux or a third party.

Image description

Reference

Top comments (0)