从零开始用 Go 编写 HTTP Server:第二部分
Home Blog Tags Read List Coding Challenge
从零开始用 Go 编写一个 HTTP Server:第二部分
跟着我来一起改进我用 Go 从零开始编写的 HTTPServer。 2025 年 3 月
去年我写了一篇 博客文章,解释了我如何通过 Coder Crafters 课程用 Golang 构建我的 HTTP Server。我收到了一些很好的反馈,并对 HTTP Server 进行了相当大的改进。让我们深入了解这些变化!
如果您想查看整个代码库,git 仓库 仍然可用。
第一个单元测试
让我们从添加一个单元测试开始。我之前一直依赖于 Codecrafters 的测试套件,但现在我想拥有一些自己的单元测试。
这应该模仿 Codecrafters 测试套件的第一个阶段:
func TestServerStart(t *testing.T) {
// 启动 server
router := server.NewServer()
go router.Start()
// 给 server 一些启动时间
time.Sleep(100 * time.Millisecond) // 不是最健壮的,但足以启动
// 尝试连接到 server
conn, err := net.Dial("tcp", "localhost:4221")
if err != nil {
t.Fatalf("Could not connect to server: %v", err)
}
defer conn.Close()
t.Log("Successfully connected to the server")
}
修复读者发现的问题
Header 应该大小写不敏感并接受多个值
一条 Reddit 评论提到,header 应该大小写不敏感,并且其中一些 header 可能有多个值。这意味着我原本简单的 Headers map[string]string
是不正确的!
在阅读了 Golang 中 HTTP 包的文档后,我最终得到了这个:
type Header map[string][]string // 现在是一个字符串数组
func (header Header) Get(key string) string {
if values, ok := header[strings.ToUpper(key)]; ok && len(values) > 0 {
return values[0] // get 只返回第一个值!要获取所有值,应使用 .Values()
}
return ""
}
func (header Header) Set(key string, value string) {
header[strings.ToUpper(key)] = []string{value} // 在与 header 交互时,我总是使用 ToUpper
}
func (header Header) Add(key string, value string) {
header[strings.ToUpper(key)] = append(header[strings.ToUpper(key)], value)
}
与其使用 toUpper
,我本可以使用 textproto.CanonicalMIMEHeaderKey(s)
,但在这部 “从零开始” 系列中,感觉有点像作弊,而且我今天不想自己实现它!
我还添加了第二个测试来检查我是否正确解析了请求。
func TestParseRequest(t *testing.T) {
rawRequest := "GET /index.html HTTP/1.1\r\n" +
"Host: www.example.com\r\n" +
"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36\r\n" +
"Content-Length: 3\r\n" +
"\r\n" +
"abc"
request, err := parseRequest([]byte(rawRequest))
if err != nil {
t.Errorf("Expected no error, got %s", err)
}
if request.Method != "GET" {
t.Errorf("Expected method GET, got %s", request.Method)
}
if request.Url.Original != "/index.html" {
t.Errorf("Expected path /index.html, got %s", request.Url.Original)
}
if request.Headers.Get("Host") != "www.example.com" {
t.Errorf("Expected Host header www.example.com, got %s", request.Headers["Host"])
}
expectedUserAgent := "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
if request.Headers.Get("User-Agent") != expectedUserAgent {
t.Errorf("Expected User-Agent header %s, got %s", expectedUserAgent, request.Headers["User-Agent"])
}
if string(request.Body) != "abc" {
t.Errorf("Expected body %s, got %s", "abc", string(request.Body))
}
}
以流的形式发送响应,而不是作为单个字符串
下一个评论提到我应该允许响应作为流(即 Go 中的 io.Writer)。在大多数情况下,这非常简单,我可以简单地将每个 str += "some string"
替换为 io.WriteString(w, "some string")
。
并将连接传递给 write 方法:route.Callback(*request).Write(conn)
处理更大的 payloads
你还记得上一篇文章中的这个吗?
// 暂时不处理更大的 payload
rawReq := make([]byte, 4096)
好吧,是时候了。
rawReq := make([]byte, 0)
for {
// 如果 requestLength % 4096 == 0,它将会挂起
buffer := make([]byte, 4096)
n, err := conn.Read(buffer)
if n > 0 {
rawReq = append(rawReq, buffer[:n]...)
}
if n < 4096 || err != nil {
break
}
}
这是另一个非常简单的实现,但它现在可以处理更大的 payloads!但是,如果 payload 是 4096 的倍数,它会中断,因为可能没有剩余可读的内容,但循环仍在等待。
为了解决这个问题,我添加了:
conn.SetReadDeadline(time.Now().Add(10 * time.Millisecond)) // 确保连接不会因无缘无故等待数据而挂起
我非常确定正确的实现是开始解析数据,直到我找到 content-length 以确切知道我应该读取多少,但现在这已经足够好了!
来自我的列表的新功能
Middleware
Middlewares 很酷,这是一个简单的概念,但我很好奇实现起来有多难!
现在,Server 有一个 middleware 数组,客户端可以添加这些 middleware
type Server struct {
Routes []Route
Middlewares []func(Handler) Handler
}
type Handler func(request HTTPRequest) HTTPResponse
func (server *Server) Use(middleware func(Handler) Handler) {
server.Middlewares = append(server.Middlewares, middleware)
}
在 listenReq
函数中,我们创建一个链,该链从分配给路由的方法开始,然后是所有 middleware,顺序与分配顺序相反。一旦链完成,我们就调用链中的最后一个函数,它会触发链式反应,返回到回调!
nextRequest := route.Callback
for i := len(middlewares) - 1; i >= 0; i-- {
nextRequest = middlewares[i](nextRequest)
}
err := nextRequest(*request).Write(conn)
fmt.Println("Error while writing the response", err)
客户端代码如下所示:
func main() {
router := server.NewServer()
router.AddRoute("/", home, "GET")
router.AddRoute("/echo/{str}", echo, "GET")
router.Use(timingMiddleware)
router.Use(loggingMiddleware)
router.Start()
}
func loggingMiddleware(next server.Handler) server.Handler {
return func(req server.HTTPRequest) server.HTTPResponse {
fmt.Println("Receiving call on ", req.Url.Original)
resp := next(req)
fmt.Println("Received call on ", req.Url.Original)
return resp
}
}
func timingMiddleware(next server.Handler) server.Handler {
return func(req server.HTTPRequest) server.HTTPResponse {
start := time.Now()
resp := next(req)
duration := time.Since(start)
fmt.Printf("%s %s - %d (%v)\n", req.Method, req.Url.Original, resp.Code, duration)
return resp
}
}
多么优雅!(好吧,我不知道它是否真的那么优雅,但我对它非常满意,代码比我想象的要简单!)
Query string 参数
下一步:Query 参数。
我在尝试匹配 URI 之前,从原始 URL 中提取这些参数。
目前,我将所有这些参数存储为字符串,并让用户在他们期望其他内容时进行转换。
uri, queryParamString, found := strings.Cut(request.Url.Original, "?")
uriParts := strings.Split(uri, "/")[1:]
queryParameters := make(map[string]string)
if found {
for _, parameter := range strings.Split(queryParamString, "&") {
keyValue := strings.Split(parameter, "=")
if len(keyValue) > 1 {
queryParameters[keyValue[0]] = keyValue[1]
} else {
queryParameters[keyValue[0]] = "true"
}
}
}
演示应用程序可以像这样访问它们
func echo(request server.HTTPRequest) server.HTTPResponse {
content := request.Url.Parameters["str"]
if val, ok := request.Url.QueryParameters["repeat"]; ok && val == "true" {
content = strings.Repeat(content, 2)
}
headers := make(server.Header)
headers.Set("Content-Type", "text/plain")
return server.HTTPResponse{
Code: server.StatusOK,
Headers: headers,
Body: []byte(content),
Request: &request,
}
}
Subrouter
为了改善声明多个路由的 UX,并且仅将 middleware 分配给某些路由,让我们添加 subrouter 功能。
我们的 Server
类型接受其他 Server
作为 Subrouter,我通过 server 上的 Subrouter 方法创建它们。
type Server struct {
Routes []Route
SubRouters []*Server
Middlewares []func(Handler) Handler
Prefix string
}
func (server *Server) SubRouter(prefix string) *Server {
subRouter := Server{
Prefix: prefix,
}
server.SubRouters = append(server.SubRouters, &subRouter)
return &subRouter
}
接下来是匹配,我保留了与之前相同的代码来检查路由,但现在它位于一个名为 match
的单独函数中。
如果找不到路由,此函数将迭代具有正确前缀的不同 subrouter,然后在这些 subrouter 上再次调用 match
。
func match(request HTTPRequest, uriParts []string, server Server) (func(HTTPRequest) HTTPResponse, map[string]string, []func(Handler) Handler) {
// 匹配路由的代码
// [...]
SUBROUTERS:
for _, subrouter := range server.SubRouters {
prefixParts := strings.Split(subrouter.Prefix, "/")[1:]
parametersPrefix := make(map[string]string)
for i := 0; i < len(prefixParts); i++ {
if strings.HasPrefix(prefixParts[i], "{") && strings.HasSuffix(prefixParts[i], "}") {
parametersPrefix[prefixParts[i][1:len(prefixParts[i])-1]] = uriParts[i]
continue
}
if prefixParts[i] == uriParts[i] {
continue
}
continue SUBROUTERS
}
res, parameters, middlewares := match(request, uriParts[len(prefixParts):], *subrouter)
if res != nil {
maps.Copy(parametersPrefix, parameters)
return res, parametersPrefix, append(server.Middlewares, middlewares...) // 我们与主路由器共享其 subrouter 的 middleware
}
}
return nil, map[string]string{}, server.Middlewares
}
在演示应用程序中,客户端现在可以像这样使用 subrouter
router := server.NewServer()
router.AddRoute("/", home, "GET")
router.AddRoute("/echo/{str}", echo, "GET")
router.AddRoute("/user-agent", userAgent, "GET")
router.AddRoute("/files/{filename}", getFile, "GET")
router.AddRoute("/files/{filename}", createFile, "POST")
v2Router := router.SubRouter("/v2/{aa}")
v2Router.AddRoute("/echo/{str}", echo, "GET")
v2Router.Use(timingMiddleware)
router.Use(loggingMiddleware)
router.Start()
路由器的 middleware 与其 subrouter 共享。
结束,再次
我们完成了这组改进,3 个修复和 3 个新功能!
除非我犯了一些重大错误或想尝试一个非常有趣的想法,否则我认为我近期不会再处理这个项目。
正如你所看到的,我没有为所有新旧功能编写足够的单元测试,这很遗憾,我应该改进这一点,特别是对于这种学习项目。
虽然我不认为我会写第三部分,但我还有两个基于 Codecrafters 课程构建的学习项目,它们可能值得拥有自己的帖子:grep 和 shell。你可以在未来几个月内看到有关其中之一的撰写。