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。你可以在未来几个月内看到有关其中之一的撰写。

go software RSS