本文介绍了如何在 TCP 协议之上,利用 Go 语言实现一个简单的 HTTP 服务器。

首先,新建一个项目,命名为 simple-http-server。通过如下命令创建项目目录,并初始化 Go 模块:

$ mkdir simple-http-server
$ cd simple-http-server
$ go mod init simple-http-server

接下来创建main.go。本地 8080 端口启动 TCP 监听服务。当有新的连接到来时,每个连接均由一个独立的 goroutine 进行处理,从而保证服务器能够并发处理多个请求。

实现HTTP GET方法

监听 TCP 链接

package main

import (
	"fmt"
	"log"
	"net"
)

func main() {
	listener, err := net.Listen("tcp", "localhost:8080") // 监听本地 8080 端口
	if err != nil {
		log.Fatal(err)
	}
	defer listener.Close()

	fmt.Println("Server listening on localhost:8080")

	for {
		conn, err := listener.Accept()
		if err != nil {
			log.Println("Error accepting connection:", err)
			continue
		}
		go handleConnection(conn) // 使用 goroutine 处理每个连接
	}
}

// handleConnection 用于处理单个连接
func handleConnection(conn net.Conn) {
}

读取 HTTP 请求报文

在建立了基本的 TCP 连接之后,接下来的任务是读取 HTTP 请求报文。在 handleConnection 函数中,将读取客户端发送的请求数据,并简单打印出来。

func handleConnection(conn net.Conn) {
	defer conn.Close()

	buf := make([]byte, 4096)
	n, err := conn.Read(buf)
	if err != nil {
		log.Println("Error reading from connection:", err)
		return
	}
	request := string(buf[:n])
	log.Println("收到请求:\n", request)
}

解析请求行

随后,对 HTTP 请求报文中的请求行进行解析。请求行包含请求方法、路径以及 HTTP 版本号,解析后可打印出各个字段,便于调试和后续处理。

	// 按照 HTTP 协议,第一行为请求行,格式为 "METHOD PATH VERSION"
	lines := strings.Split(request, "\r\n")
	if len(lines) < 1 {
		log.Println("无效的 HTTP 请求")
		return
	}
	requestLine := lines[0]
	parts := strings.Split(requestLine, " ")
	if len(parts) < 3 {
		log.Println("无效的 HTTP 请求行")
		return
	}

	method := parts[0]
	path := parts[1]
	version := parts[2] // 如果需要可以使用

	log.Println("方法: ", method, "路径: ", path, "版本: ", version)

返回响应

针对 HTTP 方法的限制,程序仅支持 GET 方法。如果请求的方法非 GET,则返回 405 状态码。否则,构造一个简单的 HTML 响应,将请求的路径在页面中显示出来。响应报文中包括状态行、必要的响应头(如 Content-Length 和 Content-Type)以及响应体内容。

	// 这里只支持 GET 方法
	if method != "GET" {
		response := "HTTP/1.1 405 Method Not Allowed\r\n" +
			"Content-Length: 0\r\n" +
			"Connection: close\r\n" +
			"\r\n"
		_, err := conn.Write([]byte(response))
		if err != nil {
			log.Println("Error writing response:", err)
		}
		return
	}

	// 构造响应体
	body := fmt.Sprintf("<html><body><h1>Hello, World!</h1><p>You requested: %s</p></body></html>", path)

	// 构造 HTTP/1.1 响应
	response := "HTTP/1.1 200 OK\r\n"
	response += fmt.Sprintf("Content-Length: %d\r\n", len(body))
	response += "Content-Type: text/html; charset=utf-8\r\n"
	response += "Connection: close\r\n"
	response += "\r\n"
	response += body

	_, err = conn.Write([]byte(response))
	if err != nil {
		log.Println("Error writing response:", err)
	}

运行程序后,如果请求根目录,将会显示一页包含请求路径信息的页面,如下图所示。

HTTP Hello,World!

通过 HTTP 获取文件

进一步完善程序的功能,使之能够通过 HTTP 获取文件。为此,添加了两个辅助函数。一个辅助函数用于根据文件后缀获取 MIME 类型,另一个辅助函数根据请求路径返回一个 io.Reader。对于根目录的请求,getFile 函数返回一个默认的 HTML 页面,其中包含 “Hello, World!” 与请求路径;对于其它路径,则尝试从当前目录读取对应的文件。

辅助函数 getMimeType 实现如下:

// getMimeType 根据文件后缀返回对应的 MIME 类型
func getMimeType(filename string) string {
	ext := filepath.Ext(filename)
	mType := mime.TypeByExtension(ext)
	if mType == "" {
		mType = "application/octet-stream"
	}
	return mType
}

而 getFile 函数的实现如下所示。getFile 函数对请求路径进行了清理,防止目录穿越攻击,并采用了简单的逻辑判断:当请求根目录时,返回内置的默认页面;否则尝试打开对应的文件并返回文件句柄。

// getFile 根据请求路径返回一个 io.Reader
// 如果路径为根目录,则返回一个包含 "Hello, World!" 及请求路径的 Reader;
// 否则,尝试从当前目录读取对应的文件。
func getFile(path string) io.Reader {
	cleanPath := filepath.Clean(path)
	// 如果路径为根目录,则返回默认内容
	if cleanPath == "/" {
		content := fmt.Sprintf("<html><body><h1>Hello, World!</h1><p>You requested: %s</p></body></html>", path)
		return strings.NewReader(content)
	}

	// 拼接当前目录下的文件路径
	file, err := os.Open("." + cleanPath)
	if err != nil {
		log.Println("Error opening file:", err)
		return nil
	}
	return file
}

在修改 handleConnection 函数时,将固定返回的内容替换为通过 getFile 函数获取的文件或默认页面。读取到的内容随后根据文件后缀判断 MIME 类型,并通过构造响应头返回给客户端。此部分代码如下:

// 此处不再修改根路径,直接将 path 传入 getFile 处理
	fileReader, mimeType := getFile(path)
	if fileReader == nil {
		// 文件不存在则返回 404
		response := "HTTP/1.1 404 Not Found\r\n" +
			"Content-Length: 0\r\n" +
			"Connection: close\r\n" +
			"\r\n"
		_, _ = conn.Write([]byte(response))
		return
	}

	// 读取文件或默认内容
	content, err := io.ReadAll(fileReader)
	if err != nil {
		log.Println("Error reading content:", err)
		response := "HTTP/1.1 500 Internal Server Error\r\n" +
			"Content-Length: 0\r\n" +
			"Connection: close\r\n" +
			"\r\n"
		_, _ = conn.Write([]byte(response))
		return
	}

	header := fmt.Sprintf("HTTP/1.1 200 OK\r\nContent-Length: %d\r\nContent-Type: %s\r\nConnection: close\r\n\r\n", len(content), mimeType)
	_, err = conn.Write([]byte(header))
	if err != nil {
		log.Println("Error writing header:", err)
		return
	}
	_, err = conn.Write(content)
	if err != nil {
		log.Println("Error writing content:", err)
		return
	}

在工作目录下放置一个test.txt文件

$ echo "Hello, http" > test.txt

运行程序后,通过浏览器访问相应文件路径即可看到 test.txt 文件的内容。效果如下图所示。

读取test.txt文件

HTTP 文件目录服务器

进一步扩展程序功能,实现 HTTP 文件目录服务器。该功能允许通过浏览器浏览指定工作目录下的所有文件,并支持点击目录项进入子目录或下载文件。首先引入命令行选项,指定服务器使用的工作目录。为此,声明了一个全局变量 rootDir,并在 init 函数中通过 flag 包绑定了 -d 参数,默认工作目录为当前目录。在 main 函数开头调用 flag.Parse() 解析命令行参数。

指定工作目录

指定工作目录

声明全局变量rootDir以及init()函数

var rootDir string

func init() {
    flag.StringVar(&rootDir, "d", ".", "设置工作目录")
}

并在main函数的开头添加 flag.Parse()

func main() {
    flag.Parse()
    // 后续代码...
}

通过模板生成 HTML 页面

目录页面的生成依赖于 HTML 模板和两个辅助函数。首先定义一个 HTML 模板,该模板展示当前目录路径和目录项列表。模板中利用了数据填充语法来生成目录列表页面。

const templateStr = `
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Directory Listing of {{.CurrentPath}}</title>
</head>
<body>
    <h1>Directory Listing of {{.CurrentPath}}</h1>
    <ul>
        {{range .Entries}}
            <li><a href="{{.URL}}">{{.Name}}</a></li>
        {{end}}
    </ul>
</body>
</html>
`

随后通过 os.ReadDir 函数获取指定目录下的所有条目,生成每个目录项对应的名称与 URL。目录项数据结构定义为 DirEntry,辅助函数 listDirectoryEntries 负责读取目录并构造目录项列表:

type DirEntry struct {
	Name string
	URL  string
}

func listDirectoryEntries(dirPath, currentURL string) ([]DirEntry, error) {
	entries, err := os.ReadDir(dirPath)
	if err != nil {
		return nil, err
	}
	var result []DirEntry
	for _, entry := range entries {
		name := entry.Name()
		url := filepath.Join(currentURL, name)
		if entry.IsDir() {
			url += "/" // 目录末尾加斜杠,便于区分
		}
		result = append(result, DirEntry{
			Name: name,
			URL:  url,
		})
	}
	return result, nil
}

利用模板生成目录页面的函数 generateDirectoryPage 接受文件系统中的绝对目录路径以及当前请求的 URL。该函数调用 listDirectoryEntries 获取目录项列表,然后通过 text/template 包将数据填充至 HTML 模板中,并将生成的页面作为 io.Reader 返回。

type DirListingData struct {
	CurrentPath string
	Entries     []DirEntry
}

func generateDirectoryPage(fsDir, currentURL string) io.Reader {
	entries, err := listDirectoryEntries(fsDir, currentURL)
	if err != nil {
		// 出错时可以生成一个简单的错误页面
		return strings.NewReader("Error reading directory")
	}

	data := DirListingData{
		CurrentPath: currentURL,
		Entries:     entries,
	}

	// 假设 templateStr 是上面给出的模板内容
	tmpl, err := template.New("dirList").Parse(templateStr)
	if err != nil {
		log.Println("Template parse error:", err)
		return strings.NewReader("Template error")
	}

	var buf bytes.Buffer
	err = tmpl.Execute(&buf, data)
	if err != nil {
		log.Println("Template execution error:", err)
		return strings.NewReader("Error generating page")
	}

	return &buf
}

最后,修改 getFile 函数使其支持目录请求。当传入路径对应于目录时,通过 os.Stat 判断后调用 generateDirectoryPage 生成目录列表页面;否则正常打开文件返回文件。

func getFile(path string) (io.Reader, string) {
	// 清理路径并拼接到根目录(假设 rootDir 已定义)
	cleanPath := filepath.Clean(path)
	fullPath := filepath.Join(rootDir, cleanPath)

	// 判断路径是否存在以及是文件还是目录
	info, err := os.Stat(fullPath)
	if err != nil {
		log.Println("Error stating path:", err)
		return nil, ""
	}

	// 如果是目录,则生成目录列表页面,MIME 类型固定为 text/html
	if info.IsDir() {
		return generateDirectoryPage(fullPath, cleanPath), "text/html"
	}

	// 否则,打开文件
	file, err := os.Open(fullPath)
	if err != nil {
		log.Println("Error opening file:", err)
		return nil, ""
	}

	// 使用 getMimeType 获取 MIME 类型
	mimeType := getMimeType(fullPath)
	return file, mimeType
}

运行程序后,将会看到根目录下的所有文件和目录以列表方式展示,点击目录项可进入子目录或下载文件。

列出根目录下的文件

实现 Keep-alive

修改并拆分handleConnection

将原来handleConnection的功能移动到辅助processHTTPRequest中

func processHTTPRequest(conn net.Conn, req *HTTPRequest, keepAlive bool) error {
    // 这里只处理 GET 方法,其他方法可返回 405
    if req.Method != "GET" {
        resp := "HTTP/1.1 405 Method Not Allowed\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
        _, err := conn.Write([]byte(resp))
        return err
    }

    // 调用 getFile,根据请求路径获取内容和 MIME 类型
    reader, mimeType := getFile(req.Path)
    if reader == nil {
        // 返回 404 页面
        resp := "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
        _, err := conn.Write([]byte(resp))
        return err
    }

    content, err := io.ReadAll(reader)
    if err != nil {
        resp := "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
        _, err = conn.Write([]byte(resp))
        return err
    }

    // 构造响应头,根据 keep-alive 状态设置 Connection 头
    header := fmt.Sprintf("HTTP/1.1 200 OK\r\nContent-Length: %d\r\nContent-Type: %s\r\n", len(content), mimeType)
    if keepAlive {
        header += "Connection: keep-alive\r\n\r\n"
    } else {
        header += "Connection: close\r\n\r\n"
    }

    if _, err = conn.Write([]byte(header)); err != nil {
        return err
    }
    if _, err = conn.Write(content); err != nil {
        return err
    }
    return nil
}

在handleConnection中只留下最基本的处理逻辑

func handleConnection(conn net.Conn) {
    defer conn.Close()
    // 循环处理多个请求
    for {
        // 设置读取超时,防止空闲连接占用资源
        conn.SetReadDeadline(time.Now().Add(30 * time.Second))

        req, err := readHTTPRequest(conn)
        if err != nil {
            // 超时或读取错误时退出循环
            log.Println("Error reading HTTP request:", err)
            break
        }

        // 判断是否需要保持连接
        keepAlive := shouldKeepAlive(req)

        // 处理请求并写入响应
        if err := processHTTPRequest(conn, req, keepAlive); err != nil {
            log.Println("Error processing request:", err)
            break
        }

        // 如果此次请求要求关闭连接,则退出循环
        if !keepAlive {
            break
        }
    }
}

读取并解析HTTP请求的辅助函数readHTTPRequest

type HTTPRequest struct {
    Method  string
    Path    string
    Version string
    Headers map[string]string
}

func readHTTPRequest(conn net.Conn) (*HTTPRequest, error) {
    reader := bufio.NewReader(conn)

    // 读取请求行,例如 "GET /path HTTP/1.1"
    line, err := reader.ReadString('\n')
    if err != nil {
        return nil, err
    }
    line = strings.TrimSpace(line)
    parts := strings.Split(line, " ")
    if len(parts) < 3 {
        return nil, fmt.Errorf("invalid request line")
    }
    req := &HTTPRequest{
        Method:  parts[0],
        Path:    parts[1],
        Version: parts[2],
        Headers: make(map[string]string),
    }

    // 读取所有请求头,直到遇到空行
    for {
        headerLine, err := reader.ReadString('\n')
        if err != nil {
            return nil, err
        }
        headerLine = strings.TrimSpace(headerLine)
        if headerLine == "" {
            break // 空行表示请求头结束
        }
        headerParts := strings.SplitN(headerLine, ":", 2)
        if len(headerParts) == 2 {
            key := strings.ToLower(strings.TrimSpace(headerParts[0]))
            value := strings.TrimSpace(headerParts[1])
            req.Headers[key] = value
        }
    }
    return req, nil
}

辅助函数shouldKeepAlive,判断是否保持连接

func shouldKeepAlive(req *HTTPRequest) bool {
    // 默认 HTTP/1.1 连接保持,HTTP/1.0 默认不保持
    connectionHeader, exists := req.Headers["connection"]

    if req.Version == "HTTP/1.1" {
        // HTTP/1.1 默认 keep-alive,除非明确写了 "close"
        if exists && strings.ToLower(connectionHeader) == "close" {
            return false
        }
        return true
    } else if req.Version == "HTTP/1.0" {
        // HTTP/1.0 默认关闭,除非明确指定 "keep-alive"
        if exists && strings.ToLower(connectionHeader) == "keep-alive" {
            return true
        }
        return false
    }
    return false
}

使用curl对keep-alive进行测试,可以看到Content: keep-alive出现在报文中,并且在一个TCP连接中提交了两个HTTP请求。

$ curl -v http://localhost:8080/ --next http://localhost:8080/test.txt 
* Host localhost:8080 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:8080...
* connect to ::1 port 8080 from ::1 port 59320 failed: Connection refused
*   Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080
* using HTTP/1.x
> GET / HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.12.0
> Accept: */*
> 
* Request completely sent off
< HTTP/1.1 200 OK
< Content-Length: 378
< Content-Type: text/html
< Connection: keep-alive
< 

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Directory Listing of /</title>
</head>
<body>
    <h1>Directory Listing of /</h1>
    <ul>
        
            <li><a href="/go.mod">go.mod</a></li>
        
            <li><a href="/main.go">main.go</a></li>
        
            <li><a href="/test.txt">test.txt</a></li>
        
    </ul>
</body>
</html>
* Connection #0 to host localhost left intact
* Re-using existing connection with host localhost
> GET /test.txt HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.12.0
> Accept: */*
> 
* Request completely sent off
< HTTP/1.1 200 OK
< Content-Length: 12
< Content-Type: text/plain; charset=utf-8
< Connection: keep-alive
< 
Hello, http
* Connection #0 to host localhost left intact

支持 Content-Range 头部字段

首先,修改 getFile 函数,使其返回一个实现了 io.ReadSeeker 接口的对象和 MIME 类型。这一步保证了在处理范围请求时,可以调用 Seek 定位到文件中指定的位置。

func getFile(path string) (io.ReadSeeker, string) {
	cleanPath := filepath.Clean(path)
	fullPath := filepath.Join(rootDir, cleanPath)
	info, err := os.Stat(fullPath)
	if err != nil {
		log.Println("Error stating path:", err)
		return nil, ""
	}

	// 如果是目录,则生成目录列表页面,返回一个 strings.Reader(虽然它不支持 Seek,但目录请求通常不支持范围请求)
	if info.IsDir() {
		reader := generateDirectoryPage(fullPath, cleanPath) // 生成目录 HTML 页面
		return reader, "text/html"
	}

	file, err := os.Open(fullPath) // *os.File 实现了 io.ReadSeeker
	if err != nil {
		log.Println("Error opening file:", err)
		return nil, ""
	}
	mimeType := getMimeType(fullPath)
	return file, mimeType
}

添加辅助函数 parseRangeHeader,用于解析请求头中的 Range 值。该函数支持解析形如 bytes=START-END(或者后缀形式如 bytes=-N)的格式,并根据资源的总大小计算出实际的起始和结束位置。

func parseRangeHeader(rangeHeader string, fileSize int64) (start, end int64, err error) {
	// 假设格式为 "bytes=start-end"
	const prefix = "bytes="
	if !strings.HasPrefix(rangeHeader, prefix) {
		return 0, 0, fmt.Errorf("invalid range header")
	}
	spec := strings.TrimPrefix(rangeHeader, prefix)
	parts := strings.Split(spec, "-")
	if len(parts) != 2 {
		return 0, 0, fmt.Errorf("invalid range spec")
	}
	if parts[0] == "" { // 后缀范围,如 "-500"
		suffix, err := strconv.ParseInt(parts[1], 10, 64)
		if err != nil {
			return 0, 0, err
		}
		if suffix > fileSize {
			suffix = fileSize
		}
		return fileSize - suffix, fileSize - 1, nil
	}
	// 解析起始位置
	start, err = strconv.ParseInt(parts[0], 10, 64)
	if err != nil {
		return 0, 0, err
	}
	if parts[1] != "" {
		end, err = strconv.ParseInt(parts[1], 10, 64)
		if err != nil {
			return 0, 0, err
		}
	} else {
		end = fileSize - 1
	}
	if start > end || end >= fileSize {
		return 0, 0, fmt.Errorf("invalid range: start=%d, end=%d, fileSize=%d", start, end, fileSize)
	}
	return start, end, nil
}

processHTTPRequest 函数中,首先检查请求头中是否包含 Range 字段。如果存在,就不读取整个内容,而是调用新的辅助函数 handleRangeRequest

handleRangeRequest 首先定位到文件中 Range 请求指定的位置(调用 Seek),然后读取所请求范围内的内容。根据是否需要保持连接(keep-alive),构造正确的响应头和状态码,再将数据发送给客户端。

func handleRangeRequest(conn net.Conn, req *HTTPRequest, rs io.ReadSeeker, mimeType string, keepAlive bool) error {
	file, ok := rs.(*os.File)
	if !ok {
		resp := "HTTP/1.1 416 Range Not Satisfiable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
		conn.Write([]byte(resp))
		return fmt.Errorf("range request not supported")
	}
	fi, err := file.Stat()
	if err != nil {
		return err
	}
	fileSize := fi.Size()

	rangeHeader := req.Headers["range"]
	start, end, err := parseRangeHeader(rangeHeader, fileSize)
	if err != nil {
		resp := "HTTP/1.1 416 Range Not Satisfiable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
		conn.Write([]byte(resp))
		return err
	}
	length := end - start + 1

	_, err = rs.Seek(start, io.SeekStart)
	if err != nil {
		return err
	}
	content := make([]byte, length)
	n, err := io.ReadFull(rs, content)
	if err != nil || int64(n) != length {
		return fmt.Errorf("failed to read requested range")
	}

	connState := "keep-alive"
	if !keepAlive {
		connState = "close"
	}
	header := fmt.Sprintf("HTTP/1.1 206 Partial Content\r\nContent-Length: %d\r\nContent-Type: %s\r\n",
		len(content), mimeType)
	header += fmt.Sprintf("Content-Range: bytes %d-%d/%d\r\nConnection: %s\r\n\r\n",
		start, end, fileSize, connState)
	conn.Write([]byte(header))
	conn.Write(content)
	return nil
}

使用curl进行测试

$ curl -v --header "Range: bytes=0-3" http://localhost:8080/random.txt --next --header "Connection: close" http://localhost:8080/test.txt

* Host localhost:8080 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:8080...
* connect to ::1 port 8080 from ::1 port 49028 failed: Connection refused
*   Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080
* using HTTP/1.x
> GET /random.txt HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.12.0
> Accept: */*
> Range: bytes=0-3
> 
* Request completely sent off
< HTTP/1.1 206 Partial Content
< Content-Length: 4
< Content-Type: text/plain; charset=utf-8
< Content-Range: bytes 0-3/1024
< Connection: keep-alive
< 
* Connection #0 to host localhost left intact
5Nbc* Re-using existing connection with host localhost
> GET /test.txt HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.12.0
> Accept: */*
> Connection: close
> 
* Request completely sent off
< HTTP/1.1 200 OK
< Content-Length: 12
< Content-Type: text/plain; charset=utf-8
< Connection: close
< 
Hello, http
* shutting down connection #0