CppGuide社区 CppGuide社区
首页
  • 最新谷歌C++风格指南(含C++17/20)
  • C++17详解
  • C++20完全指南
  • C++23快速入门
  • C++语言面试问题集锦
  • 🔥C/C++后端开发常见面试题解析 (opens new window)
  • 网络编程面试题 (opens new window)
  • 网络编程面试题 答案详解 (opens new window)
  • 聊聊WebServer作面试项目那些事儿 (opens new window)
  • 字节跳动面试官现身说 (opens new window)
  • 技术简历指南 (opens new window)
  • 🔥交易系统开发岗位求职与面试指南 (opens new window)
  • 第1章 高频C++11重难点知识解析
  • 第2章 Linux GDB高级调试指南
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 高性能网络通信协议设计精要
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 后端服务重要模块设计探索
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 源码分析系列

    • leveldb源码分析
    • libevent源码分析
    • Memcached源码分析
    • TeamTalk源码分析
    • 优质源码分享 (opens new window)
    • 🔥远程控制软件gh0st源码分析
  • 从零手写C++项目系列

    • C++游戏编程入门(零基础学C++)
    • 🔥使用C++17从零开发一个调试器 (opens new window)
    • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
    • 🔥使用C++从零写一个C语言编译器 (opens new window)
    • 从零用C语言写一个Redis
  • Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • Go语言特性

    • Go开发实用指南
    • Go系统接口编程
    • 高效Go并发编程
    • Go性能调优
    • Go项目架构设计
  • Go项目实战

    • 使用Go从零开发一个数据库
    • 🔥使用Go从零开发一个编译器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥用Go从零写一个编排器(类Kubernetes) (opens new window)
  • Rust编程

    • Rust编程指南
  • 数据库

    • SQL零基础指南
    • MySQL开发与调试指南
GitHub (opens new window)
首页
  • 最新谷歌C++风格指南(含C++17/20)
  • C++17详解
  • C++20完全指南
  • C++23快速入门
  • C++语言面试问题集锦
  • 🔥C/C++后端开发常见面试题解析 (opens new window)
  • 网络编程面试题 (opens new window)
  • 网络编程面试题 答案详解 (opens new window)
  • 聊聊WebServer作面试项目那些事儿 (opens new window)
  • 字节跳动面试官现身说 (opens new window)
  • 技术简历指南 (opens new window)
  • 🔥交易系统开发岗位求职与面试指南 (opens new window)
  • 第1章 高频C++11重难点知识解析
  • 第2章 Linux GDB高级调试指南
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 高性能网络通信协议设计精要
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 后端服务重要模块设计探索
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 源码分析系列

    • leveldb源码分析
    • libevent源码分析
    • Memcached源码分析
    • TeamTalk源码分析
    • 优质源码分享 (opens new window)
    • 🔥远程控制软件gh0st源码分析
  • 从零手写C++项目系列

    • C++游戏编程入门(零基础学C++)
    • 🔥使用C++17从零开发一个调试器 (opens new window)
    • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
    • 🔥使用C++从零写一个C语言编译器 (opens new window)
    • 从零用C语言写一个Redis
  • Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • Go语言特性

    • Go开发实用指南
    • Go系统接口编程
    • 高效Go并发编程
    • Go性能调优
    • Go项目架构设计
  • Go项目实战

    • 使用Go从零开发一个数据库
    • 🔥使用Go从零开发一个编译器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥用Go从零写一个编排器(类Kubernetes) (opens new window)
  • Rust编程

    • Rust编程指南
  • 数据库

    • SQL零基础指南
    • MySQL开发与调试指南
GitHub (opens new window)
  • Go开发实用指南 说明
  • 第1章 Go项目如何组织
  • 第2章 字符串处理
  • 第3章 处理日期和时间
  • 第4章 使用数组、切片和映射
  • 第5章 使用类型、结构体和接口
  • 第6章 使用泛型
  • 第7章 并发
  • 第8章 错误与恐慌(panic)
  • 第9章 context包
  • 第10章 处理大量数据
  • 第11章 处理JSON数据
  • 第12章 进程
  • 第13章 网络编程
    • 第14章 流式输入/输出
    • 第15章 数据库
    • 第16章 日志记录
    • 第17章 测试、基准测试和性能分析
    目录

    第13章 网络编程

    # 第13章 网络编程

    网络编程是应用开发者的一项关键技能。要对这个主题进行全面论述是一项艰巨的任务,因此我们将关注一些你在工作中可能遇到的典型示例。需要牢记的重要一点是,网络编程是在应用程序中引入漏洞的主要因素。网络程序本质上也是并发的,这使得正确且安全的网络编程格外困难。所以,本节将包含一些在编写时考虑了安全性和可扩展性的示例。

    本章包含以下方法:

    • 编写TCP服务器
    • 编写TCP客户端
    • 编写基于行的TCP服务器
    • 使用TCP连接发送/接收文件
    • 编写TLS客户端/服务器
    • 用于TLS终止和负载均衡的TCP代理
    • 设置读写截止时间
    • 解除阻塞的读写操作
    • 编写UDP客户端/服务器
    • 进行HTTP调用
    • 运行HTTP服务器
    • HTTPS——设置TLS服务器
    • 编写HTTP处理器
    • 在文件系统上提供静态文件服务
    • 处理HTML表单
    • 编写用于下载大文件的处理器
    • 以流的形式处理HTTP上传的文件和表单

    # TCP网络

    传输控制协议(Transmission Control Protocol,TCP)是一种面向连接的协议,它提供以下保障:

    • 可靠性:发送方能够知道预期的接收方是否收到了数据。
    • 有序性:消息将按照发送的顺序被接收。
    • 错误检查:消息在传输过程中会受到保护,防止损坏。

    得益于这些保障,TCP相对易于使用。它是许多高级协议(如HTTP和WebSocket)的基础。在本节中,我们将介绍一些示例,展示如何编写TCP服务器和客户端。

    # 编写TCP服务器

    TCP服务器是一个监听网络端口上连接请求的程序。一旦与客户端建立连接,客户端和服务器之间的通信就通过net.Conn对象进行。服务器可以继续监听新的连接。这样,单个服务器就可以与多个客户端进行通信。

    # 如何操作...

    1. 选择一个用于连接客户端的端口。 这通常取决于应用程序的配置。前1024个(0到1023)端口通常要求服务器程序具有root权限。这些端口大多预留给知名的服务器程序,例如端口22用于ssh,端口80用于HTTP。1024及以上的端口是临时端口。只要没有其他程序在监听,你的服务器程序可以使用1024及以上的任何端口号,且无需额外权限。 使用端口号0可以让内核随机选择一个未使用的端口。你可以为端口0创建一个监听器,然后查询该监听器以确定选择了哪个端口号。
    2. 创建一个监听器。监听器是一种绑定地址和端口的机制。一旦你使用某个端口号创建了监听器,同一主机或同一容器内的其他进程就无法使用该端口号来监听网络流量。 以下代码片段展示了如何创建监听器:
    // 监听的地址和端口。如果未指定,使用:0随机选择端口
    addr:=":8080"
    // 创建一个TCP监听器
    listener, err := net.Listen("tcp", addr)
    if err != nil {
    	panic(err)
    }
    
    // 打印我们正在监听的地址
    fmt.Println("Listening on ", listener.Addr())
    defer listener.Close()
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11

    程序首先确定要监听的网络地址。地址的确切格式取决于所选的协议,这里是TCP协议。如果未给出主机名或IP地址,监听器将监听本地系统所有可用的单播IP地址。如果你给出了主机名或IP地址,监听器将只监听来自给定IP地址的流量。这意味着,如果你指定localhost:1234,监听器将只监听来自localhost的流量,不会监听外部流量。 前面的示例打印了listener.Addr()。如果你提供:0作为监听地址,或者根本不提供监听地址,这将非常有用。在这种情况下,监听器将监听一个随机端口,listener.Addr()将返回客户端可以连接的地址。

    1. 监听连接。使用Listener.Accept()接受传入的连接。通常在循环中进行,如下所示:
    // 监听传入的TCP连接
    for {
        // 接受一个连接
        conn, err := listener.Accept()
        if err != nil {
        	fmt.Println(err)
        	return
    	}
        
    	// 在单独的goroutine中处理该连接
    	go handleConnection(conn)
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12

    在前面的示例中,如果监听器被关闭,listener.Accept调用将失败并返回错误。 4. 在单独的goroutine中处理连接。这样,监听器可以继续接受连接,而服务器在各自的goroutine中,使用为特定客户端创建的连接与已连接的客户端进行通信。

    一个简单回显服务器的连接处理程序可以这样编写:

    func handleConnection(conn net.Conn) {
    	io.Copy(conn, conn)
    }
    
    1
    2
    3

    下面是完整的服务器程序:

    var address = flag.String("a", ":8008", "Address to listen")
    
    func main() {
        flag.Parse()
        // 创建一个TCP监听器
        listener, err := net.Listen("tcp", *address)
        if err != nil {
        	panic(err)
        }
        
        fmt.Println("Listening on ", listener.Addr())
        defer listener.Close()
        
        // 监听传入的TCP连接
        for {
            conn, err := listener.Accept()
            if err != nil {
                fmt.Println(err)
                return
            }
            
            go handleConnection(conn)
        }
    }
    
    func handleConnection(conn net.Conn) {
        io.Copy(conn, conn)
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28

    前面的程序会将从连接中读取的所有内容写回连接,形成一个回显服务。当客户端终止连接时,读取操作将返回io.EOF,从而终止复制操作。

    # 它的工作原理...

    net.Conn接口同时具有Read([]byte) (int, error)方法(这使其成为一个io.Reader)和Write([]byte) (int, error)方法(这也使其成为一个io.Writer)。因此,从连接中读取的任何内容都会被写回该连接。

    你可能会注意到,由于使用了io.Copy,每读取一个字节都会被写回连接,所以这不是一个基于行的协议。

    # 编写TCP客户端

    TCP客户端连接到在某个主机端口上监听的TCP服务器。一旦建立连接,通信就是双向的。换句话说,服务器和客户端的区别在于连接的建立方式。当我们说 “服务器” 时,指的是等待监听端口的程序;当我们说 “客户端” 时,指的是连接(“拨号”)服务器正在监听的主机端口的程序。一旦连接建立,双方就可以异步地发送和接收数据。TCP保证消息将按发送顺序被接收,并且消息不会丢失,但不保证消息何时会被对方接收。

    # 如何操作...

    1. 客户端必须知道服务器的地址和端口。这通常由环境(命令行、配置等)提供。
    2. 使用net.Dial创建与服务器的连接:
    conn, err := net.Dial("tcp", addr)
    if err != nil {
    	// 处理错误
    }
    
    1
    2
    3
    4
    1. 使用返回的net.Conn对象向服务器发送数据,或从服务器接收数据:
    // 发送一行文本
    text := []byte("Hello echo server!")
    conn.Write(text)
    
    // 读取响应
    response := make([]byte, len(text))
    conn.Read(response)
    fmt.Println(string(response))
    
    1
    2
    3
    4
    5
    6
    7
    8
    1. 使用完毕后关闭连接:
    conn.Close()
    
    1

    下面是完整的程序:

    var address = flag.String("a", ":8008", "Server address")
    func main() {
        flag.Parse()
        conn, err := net.Dial("tcp", *address)
        if err != nil {
        	panic(err)
        }
        
        // 发送一行文本
        text := []byte("Hello echo server!")
        conn.Write(text)
        
        // 读取响应
        response := make([]byte, len(text))
        conn.Read(response)
        fmt.Println(string(response))
        conn.Close()
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18

    这个示例展示了与服务器的请求 - 响应式交互。但实际情况并非总是如此。网络连接同时提供了io.Writer和io.Reader接口,它们可以并发使用。

    # 编写基于行的TCP服务器

    在本方法中,我们将介绍一个基于行而不是字节进行工作的TCP服务器。从网络连接读取行时,有一些需要注意的要点,特别是与服务器安全性相关的问题。仅仅因为你期望读取行,并不意味着客户端会发送格式正确的行。

    # 如何操作...

    1. 使用上一节给出的相同结构来设置服务器。
    2. 在连接处理程序中,使用bufio.Reader或bufio.Scanner来读取行。
    3. 使用io.LimitedReader包装连接,以限制行的长度。

    让我们看看这是如何工作的。以下示例展示了如何实现连接处理程序:

    // 将行长度限制为1KiB
    const MaxLineLength = 1024
    
    func handleConnection(conn net.Conn) error {
        defer conn.Close()
        // 用有限读取器包装连接,防止客户端发送无边界的数据量
        limiter := &io.LimitedReader {
            R: conn,
            N: MaxLineLength+1, // 多读取一个字节以检测长行
    	}
        
        reader := bufio.NewReader(limiter)
        for {
        	bytes, err := reader.ReadBytes(byte('\n'))
        	if err != nil {
                if err != io.EOF {
                    // 非流结束的其他错误
                    return err
                }
        
                // 流结束。可能是因为行太长
                if limiter.N == 0 {
                    // 行太长
                    return fmt.Errorf("Received a line that is too long")
                }
        
                // 流结束
                return nil
        	}
            
            // 重置限制器,以便可以用新的限制读取下一行
            limiter.N=MaxLineLength+1
            // 处理该行:将其发送回客户端
            if _, err := conn.Write(bytes); err != nil {
                return err
            }
        }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38

    连接处理例程首先用io.LimitedReader包装连接。这是必要的,以防止reader.ReadBytes在遇到换行符之前读取无限制的数据量。如果不这样做,恶意客户端可以发送大量没有换行符的数据,耗尽服务器的所有内存。对行长度设置硬限制可以防止这种攻击方式。每次读取一行后,我们将limiter.N重置为其原始值,以便可以使用相同的限制读取下一行。请注意,限制器设置为多读取一个字节。这是因为io.LimitedReader对于合法的文件结束符(表示客户端断开连接)和读取超出限制的情况都会返回io.EOF。如果读取超出限制,这意味着最后读取的行至少比限制多一个字节,这使我们能够判断这是一个无效行。

    # 通过TCP连接发送/接收文件

    通过TCP连接发送和接收文件展示了网络编程中的几个要点,即协议设计(涉及谁在何时发送什么内容)和编码(涉及数据元素在网络中的表示方式)。这个示例将展示如何通过TCP连接传输元数据和八位字节流。

    # 操作方法……

    1. 使用与上一节相同的结构来设置服务器。
    2. 在发送端(客户端),执行以下操作:
      • 对包含文件名、文件大小和文件模式的文件元数据进行编码并发送。
      • 发送文件内容。
      • 关闭连接。
    3. 在接收端(服务器),执行以下操作:
      • 解码文件元数据。使用给定的模式创建一个文件来存储接收到的文件内容。
      • 接收文件内容并写入文件。
      • 在接收到所有文件内容后,关闭文件。

    第一部分是文件元数据的传输。有几种方法可以实现这一点:你可以使用基于文本的编码方案,如键值对或JSON,但这类方案的问题在于它们的长度不固定。一种简单、有效且可移植的编码方案是使用encoding/binary包进行二进制编码。但这并不能解决文件名的编码问题,因为文件名不是固定长度的字符串。所以,我们在文件元数据中包含文件名的长度,并使用恰好所需的字节数对文件名进行编码。

    固定大小的fileMetadata结构体如下:

    type fileMetadata struct {
        Size    uint64
        Mode    uint32
        NameLen uint16
    }
    
    1
    2
    3
    4
    5

    这个结构体在所有平台上都是14字节(Size占8字节,Mode占4字节,NameLen占2字节)。使用binary/encoding.Write,你可以使用大端序(binary.BigEndian)或小端序(binary.LittleEndian)编码在网络上对这个固定大小的结构体进行编码,接收端将能够成功解码。

    下一章将包含有关字节序(endianness)的更详细信息。

    客户端的其余部分如下:

    var address = flag.String("a", ":8008", "Server address")
    var file = flag.String("file", "", "File to send")
    
    func main() {
        flag.Parse()
    
        // 打开文件
        file, err := os.Open(*file)
        if err != nil {
            panic(err)
        }
    
        // 连接接收方
        conn, err := net.Dial("tcp", *address)
        if err != nil {
            panic(err)
        }
    
        // 编码文件元数据
        fileInfo, err := file.Stat()
        if err != nil {
            panic(err)
        }
    
        md := fileMetadata{
            Size:    uint64(fileInfo.Size()),
            Mode:    uint32(fileInfo.Mode()),
            NameLen: uint16(len(fileInfo.Name())),
        }
    
        if err := binary.Write(conn, binary.LittleEndian, md); err != nil {
            panic(err)
        }
    
        // 文件名
        if _, err := conn.Write([]byte(fileInfo.Name())); err != nil {
            panic(err)
        }
    
        // 文件内容
        if _, err := io.Copy(conn, file); err != nil {
            panic(err)
        }
    
        conn.Close()
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46

    注意,这里使用io.Copy来传输文件的实际内容。使用io.Copy,你可以将任意大小的文件传输给接收方,而不会消耗大量内存。

    现在让我们看看服务器(接收方):

    func handleConnection(conn net.Conn) {
        defer conn.Close()
    
        // 读取文件元数据
        var meta fileMetadata
        err := binary.Read(conn, binary.LittleEndian, &meta)
        if err != nil {
            fmt.Println(err)
            return
        }
    
        // 不允许文件名过长
        if meta.NameLen > 255 {
            fmt.Println("File name too long")
            return
        }
    
        // 读取文件名
        name := make([]byte, meta.NameLen)
        _, err = io.ReadFull(conn, name)
        if err != nil {
            fmt.Println(err)
            return
        }
    
        path := filepath.Join("downloads", string(name))
    
        // 创建文件
        file, err := os.OpenFile(
            path,
            os.O_CREATE|os.O_WRONLY,
            os.FileMode(meta.Mode),
        )
        if err != nil {
            fmt.Println(err)
            return
        }
    
        defer file.Close()
    
        // 复制文件内容
        _, err = io.CopyN(file, conn, int64(meta.Size))
        if err != nil {
            // 出错时删除文件
            os.Remove(path)
            fmt.Println(err)
            return
        }
    
        fmt.Printf("Received file %s: %d bytes\n", string(name), meta.Size)
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51

    第一个操作是对文件元数据进行固定大小的读取操作。然后我们读取文件名。注意在读取文件名之前对文件名长度进行检查。这是一种重要的防御措施,用于验证和限制所有涉及从外部系统或用户读取的大小的内存分配。在这里,我们拒绝长度超过255字节的文件名。然后,我们使用给定的模式创建文件,并使用io.CopyN从输入中读取精确的文件大小字节数。如果出错,我们将删除部分下载的文件。

    # 编写TLS客户端/服务器

    传输层安全(Transport Layer Security,TLS)提供端到端加密,同时不泄露加密密钥,以防止中间人攻击。它还提供对等方身份验证和消息完整性保证。本方法展示了如何设置TLS服务器来保护网络通信。不过,首先简单介绍一下公钥密码学可能会有所帮助。

    一个加密密钥对包含一个私钥和一个公钥。私钥需要保密,公钥则公开。

    加密密钥对用于加密消息的方式如下:由于一方的公钥是公开的,任何人都可以创建一条消息,使用该公钥对其进行加密,然后将其发送给拥有私钥的一方。只有私钥的所有者才能解密该消息。这也意味着,如果私钥被泄露,任何拥有该私钥的人都可以窃听此类消息。

    加密密钥对用于确保消息完整性的方式如下:私钥的所有者可以使用其私钥创建消息的签名(哈希值)。任何拥有公钥的人都可以验证消息的完整性,也就是说,公钥可以用于验证签名是否由相应的私钥生成。

    公钥以数字证书的形式分发。数字证书是一个文件,其中包含由受信任的第三方(证书颁发机构,Certificate Authority,CA)签名的实体的公钥。有许多知名的证书颁发机构会将自己的公钥作为证书(根证书)发布,并且大多数现代操作系统都附带这些根证书。因此,当你获得一个证书时,你可以使用对其签名的证书颁发机构的公钥来验证其真实性。一旦你验证了一个公钥是真实的,你就可以连接拥有相应私钥的公钥所有者,并建立一个安全通道。

    证书颁发机构的根证书通常由该证书颁发机构自己签名。

    如果你需要为内部服务器创建证书,通常可以通过创建一个自签名的根证书来为你的环境创建一个证书颁发机构。你要对该证书颁发机构的私钥保密,并在内部发布其公钥。有一些自动化工具可以帮助你为服务器创建证书颁发机构和证书。

    # 操作方法……

    以下是设置TLS服务器和客户端的方法:

    1. 为你的服务器创建或购买一个X.509证书。如果服务器不是面向互联网的服务器,自签名证书通常就足够了。如果这是一个面向互联网的服务器,你要么从某个证书颁发机构组织获取证书,要么发布自己的公钥证书,以便想要连接到你服务器的客户端可以使用该证书进行身份验证和加密通信流量。
    2. 对于服务器,执行以下操作:
      • 使用crypto/tls.LoadX509KeyPair加载证书。
      • 使用证书创建一个crypto/tls.Config。
      • 使用crypto/tls.Listen创建一个监听器。
      • 服务器的其余部分遵循与TCP服务器相同的布局。以下代码片段说明了这些步骤:
    var (
        address     = flag.String(
            "a", ":4433", "Address to listen")
        certificate = flag.String(
            "c", "../server.crt", "Certificate file")
        key         = flag.String(
            "k", "../privatekey.pem", "Private key")
    )
    
    func main() {
        flag.Parse()
        // 2.1 加载密钥对
        cer, err := tls.LoadX509KeyPair(*certificate, *key)
        if err != nil {
            panic(err)
        }
        
        // 2.2 为监听器创建TLS配置
        config := &tls.Config{
            Certificates: []tls.Certificate{cer},
        }
        
        // 2.3 创建监听器
        listener, err := tls.Listen("tcp", *address, config)
        if err != nil {
            panic(err)
            return
        }
        
        defer listener.Close()
        fmt.Println("Listening TLS on ", listener.Addr())
        
        // 2.4 监听传入的TCP连接
        for {
            conn, err := listener.Accept()
            if err != nil {
                fmt.Println(err)
                return
            }
            
            go handleConnection(conn)
        }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43

    请注意,设置服务器既需要证书,也需要私钥。一旦设置好了TLS监听器,其余代码与未加密的TCP服务器相同。

    对于客户端,请按照以下步骤操作:

    1. 如果你使用的是知名证书颁发机构的证书,使用crypto/x509.SystemCertPool。如果你有自签名证书或其他自定义证书,使用crypto/x509.NewCertPool创建一个空的证书池。
    2. 加载服务器证书,并将其添加到证书池中。
    3. 使用crypto/tls.Dial,并使用证书池初始化TLS配置。
    4. 客户端的其余部分遵循此处描述的相同TCP客户端布局。以下代码片段展示了这些步骤:
    var (
        addr     = flag.String(
            "addr", "", "Server address")
        certFile = flag.String(
            "cert", "../server.crt", "TLS certificate file")
    )
    
    func main() {
        flag.Parse()
        // 3.1 创建新的证书池
        roots := x509.NewCertPool()
        // 3.2 加载服务器证书
        certData, err := os.ReadFile(*certFile)
        if err != nil {
            panic(err)
        }
        
        ok := roots.AppendCertsFromPEM(certData)
        if !ok {
            panic("failed to parse root certificate")
        }
        
        // 3.3 连接服务器
        conn, err := tls.Dial("tcp", *addr, &tls.Config{
            RootCAs: roots,
        })
        
        if err != nil {
            panic(err)
        }
        
        // 3.4 发送一行文本
        text := []byte("Hello echo server!")
        conn.Write(text)
        
        // 读取响应
        response := make([]byte, len(text))
        conn.Read(response)
        fmt.Println(string(response))
        conn.Close()
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41

    同样,只有当服务器证书由操作系统无法识别的证书颁发机构签名时,才需要加载证书并将其添加到证书池中。许多使用HTTPS的网站都有由知名证书颁发机构签名的证书,这就是为什么你可以在不安装自定义证书的情况下连接它们:操作系统已经信任该证书颁发机构。

    # 用于TLS终止和负载均衡的TCP代理

    大多数面向互联网的应用程序都会使用反向代理(入口网关,ingress)将内部资源与外部世界隔离开来。外部客户端通常会通过加密连接(TLS)与反向代理相连,反向代理再通过未加密的通道将请求转发到后端服务(图13.1),或者使用内部证书颁发机构(CA)重新加密连接后再转发请求。反向代理通常还会进行某种负载均衡,以便均匀地分配工作负载。

    img 图13.1 - 采用轮询负载均衡和TLS终止的TLS代理

    在本节中,我们将介绍这样一种反向代理,它接收来自外部主机的TLS流量,并使用未加密的TCP将流量转发到后端服务器,同时以轮询的方式将请求分配到这些服务器上。

    作为一名Go语言开发者,你不太可能自己编写反向代理或负载均衡器,因为已经有多种选择。不过,这是一个很有趣的应用场景,我把它写在这里是为了展示如何用Go语言实现类似功能,尤其是代理本身的实现。

    # 实现步骤……

    在这里,我们假设代理已获得可用后端服务器的列表。很多时候,你需要使用特定于平台的服务发现机制来确定哪些服务器是可用的:

    1. 使用代理主机的证书和密钥创建一个面向外部的TLS接收器。
    2. 监听传入的TLS连接。
    3. 当客户端连接时,选择一个后端服务器并建立连接。
    4. 启动一个代理协程(goroutine),将来自外部主机的所有流量转发到后端服务器,并将来自后端服务器的流量转发到外部主机。
    5. 当其中一个连接关闭时,终止代理。

    下面的程序展示了这些步骤:

    var (
        tlsAddress = flag.String(
            "a", ":4433",
            "TLS address to listen")
        serverAddresses = flag.String(
            "s", ":8080",
            "Server addresses, comma separated")
        certificate = flag.String(
            "c", "../server.crt", "Certificate file")
        key = flag.String(
            "k", "../privatekey.pem", "Private key")
    )
    
    func main() {
        flag.Parse()
        // 1. 创建面向外部的TLS接收器
        // 加载密钥对
        cer, err := tls.LoadX509KeyPair(*certificate, *key)
        if err != nil {
            panic(err)
        }
        
        // 为监听器创建TLS配置
        config := &tls.Config{
            Certificates: []tls.Certificate{cer},
        }
        // 创建TLS监听器
        tlsListener, err := tls.Listen("tcp", *tlsAddress, config)
        if err != nil {
            panic(err)
        }
        
        defer tlsListener.Close()
        fmt.Println("Listening TLS on ", tlsListener.Addr())
        // 监听传入的TLS连接
        servers := strings.Split(*serverAddresses, ",")
        fmt.Println("Forwarding to servers: ", servers)
        nextServer := 0
        for {
            // 2. 监听传入的TLS连接
            conn, err := tlsListener.Accept()
            if err != nil {
                fmt.Println(err)
                return
            }
            retries := 0
            
            for {
                // 3. 选择下一个服务器
                server := servers[nextServer]
                nextServer++
                if nextServer >= len(servers) {
                    nextServer = 0
                }
                
                // 与该服务器建立连接
                targetConn, err := net.Dial("tcp", server)
                if err != nil {
                    retries++
                    fmt.Errorf("Cannot connect to %s", server)
                    if retries > len(servers) {
                        panic("None of the servers are available")
                    }
                    continue
                }
                
                // 4. 启动代理
                go handleProxy(conn, targetConn)
            }
        }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71

    我们在前面的示例中已经介绍过设置TLS接收器的详细内容,所以现在来看看如何选择后端服务器。这个实现中给定了所有可用后端服务器的列表。每一个接受的客户端连接都会被分配到列表中的下一个服务器,由nextServer索引指向。代理使用net.Dial连接选定的服务器,如果连接失败(服务器可能暂时不可用),它会跳到列表中的下一个服务器。如果尝试了len(servers)次都失败,那么所有后端服务器都不可用,程序将终止。然而,如果成功选择了一个服务器,就会启动一个代理,主协程则返回继续监听新的连接。

    我们来看看代理处理器是如何编写的:

    func handleProxy(conn, targetConn net.Conn) {
        defer conn.Close()
        defer targetConn.Close()
        // 将数据从客户端复制到服务器
        go io.Copy(targetConn, conn)
        // 将数据从服务器复制到客户端
        io.Copy(conn, targetConn)
    }
    
    1
    2
    3
    4
    5
    6
    7
    8

    正如我在前几节中提到的,网络连接包含两个并发的数据流,一个从客户端主机流向服务器,另一个从服务器流向客户端主机。这两个数据流可能同时包含正在传输的数据。因此,代理TCP连接需要进行两个io.Copy操作,一个从服务器到客户端,另一个从客户端到服务器。此外,这些操作中至少有一个必须在单独的协程中运行。在前面的示例中,从外部连接到后端服务器的流量在一个单独的协程中复制,而从后端服务器到外部主机的流量在代理协程中复制。如果任何一方关闭连接,复制操作就会终止,这将导致最后一个复制操作终止,同时也会关闭另一个连接。

    # 设置读/写超时

    如果你不想无限期地等待已连接的主机发送数据,或者等待已连接的主机接收你发送的数据,就必须设置超时时间。

    # 实现步骤……

    根据你使用的具体协议,你可以设置读超时或写超时,并且可以选择为单个I/O操作或全局设置这些超时时间:

    1. 在操作前设置超时时间:
    conn.SetDeadline(time.Now().Add(timeoutSeconds * time.Second))
    if n, err := conn.Read(data); err != nil {
        if errors.Is(err, os.ErrDeadlineExceeded) {
            // 超时
        } else {
            // 其他错误
        }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    1. 如果在超时后还想继续使用该连接,则必须重置超时时间:
    conn.SetDeadline(time.Time{})
    
    1

    或者,设置一个未来的新超时时间。

    # 解除阻塞的读或写操作

    有时,你需要根据外部事件解除读或写操作的阻塞。本示例将展示如何解除这种I/O操作的阻塞。

    # 实现步骤……

    • 如果你想解除I/O操作的阻塞且不再打算重用该连接,可以异步关闭连接:
    cancel := make(chan struct{})
    done := make(chan struct{})
    // 如果向cancel通道发送消息,则关闭连接
    go func() {
        select {
        case <-cancel:
            conn.Close()
        case <-done:
        }
    }()
    go handleConnection(conn)
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    • 如果你想解除I/O操作的阻塞但不终止它,可以将超时时间设置为当前时间:
    unblock := make(chan struct{})
    // 如果向unblock通道发送消息,则解除连接的阻塞
    go func() {
        <-unblock
        conn.SetDeadline(time.Now())
    }()
    
    timedout := false
    if n, err := conn.Read(data); err != nil {
        if errors.Is(err, os.ErrDeadlineExceeded) {
            // 重置连接超时时间
            conn.SetDeadline(time.Time{})
            timedout = true
            // 继续使用该连接
        } else {
            // 处理错误
        }
    }
    
    if timedout {
        // 读取超时
    } else {
        // 读取未超时
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24

    # 工作原理……

    TCP读操作会一直阻塞,直到有数据可读,只有在从对等方接收到数据时才会发生。TCP写操作在发送方无法再缓冲更多数据时会阻塞。前面的示例展示了两种解除这些调用阻塞的方法。

    关闭连接会以错误的形式解除读/写操作的阻塞,因为在等待数据到达或等待数据写入时连接被关闭。关闭连接会丢弃所有未读或未写的数据,并销毁为该连接分配的所有资源。

    异步设置超时会为等待的操作设置一个截止时间,当截止时间过去后,操作会失败,但连接仍然保持打开状态。你可以重置超时时间并重新尝试该操作。

    # 编写UDP客户端/服务器

    与TCP不同,UDP(用户数据报协议)是无连接的。这意味着,与通过建立连接与对等方来回发送数据不同,你只需发送和接收数据包即可。UDP没有交付或顺序保证。

    UDP的一个重要用途是域名系统(DNS)协议。UDP也是许多流媒体协议(如IP语音、视频流等)的选择,在这些协议中,偶尔的数据包丢失是可以容忍的。网络监控工具也青睐UDP。

    尽管UDP是无连接的,但UDP网络API提供了与TCP网络API类似的接口。在这里,我们将展示一个简单的客户端 - 服务器UDP回显服务器,以演示如何使用这些API。

    # 实现步骤……

    以下步骤展示了如何编写UDP服务器:

    1. 使用net.ResolveUDPAddr解析服务器要监听的UDP地址:
    addr, err := net.ResolveUDPAddr("udp4", *address)
    if err != nil {
        panic(err)
    }
    
    1
    2
    3
    4
    1. 创建一个UDP监听器:
    // 创建一个UDP连接
    conn, err := net.ListenUDP("udp4", addr)
    if err != nil {
        panic(err)
    }
    
    defer conn.Close()
    
    1
    2
    3
    4
    5
    6
    7

    尽管net.ListenUDP返回一个*net.UDPConn,但返回的对象更像是一个监听器而不是连接。UDP是无连接的,所以这个调用会在给定地址上开始监听UDP数据包。从技术上讲,客户端不会连接服务器并启动双向流,它们只是发送一个数据包。这就是为什么在下一步中,读操作也会返回发送方的地址,以便可以发送响应。 3. 从监听器读取数据。这将返回对等方的远程地址:

    // 监听传入的UDP连接
    buf := make([]byte, 1024)
    n, remoteAddr, err := conn.ReadFromUDP(buf)
    if err != nil {
        // 处理错误
    }
    
    fmt.Printf("Received %d bytes from %s\n", n, remoteAddr)
    
    1
    2
    3
    4
    5
    6
    7
    8
    1. 使用上一步获得的地址将响应发送给对等方:
    if n > 0 {
        _, err := conn.WriteToUDP(buf[:n], remoteAddr)
        if err != nil {
            // 处理错误
        }
    }
    
    1
    2
    3
    4
    5
    6

    现在我们来看看UDP客户端:

    1. 解析服务器的地址:
    addr, err := net.ResolveUDPAddr("udp4", *serverAddress)
    if err != nil {
        panic(err)
    }
    
    1
    2
    3
    4
    1. 创建一个UDP连接。这需要一个本地地址和一个远程地址。如果本地地址为nil,则会自动选择本地地址。如果远程地址为nil,则假定为本地系统:
    // 创建一个UDP连接,本地地址随机选择
    conn, err := net.DialUDP("udp4", nil, addr)
    if err != nil {
        panic(err)
    }
    
    fmt.Printf("UDP server %s\n", conn.RemoteAddr())
    defer conn.Close()
    
    1
    2
    3
    4
    5
    6
    7
    8

    同样,UDP是无连接的。前面调用DialUDP创建了一个套接字,后续调用会使用这个套接字。它不会与服务器建立连接。 3. 使用conn.Write向服务器发送数据:

    // 发送一行文本
    text := []byte("Hello echo server!")
    n, err := conn.Write(text)
    if err != nil {
        panic(err)
    }
    
    fmt.Printf("Written %d bytes\n", n)
    
    1
    2
    3
    4
    5
    6
    7
    8
    1. 使用conn.Read从服务器读取数据:
    // 读取响应
    response := make([]byte, 1024)
    conn.ReadFromUDP(response)
    
    1
    2
    3

    # 使用HTTP

    HTTP(超文本传输协议)是一种客户端 - 服务器协议,客户端(用户代理或代理)向服务器发送请求,服务器返回响应。它是一种应用层超文本协议,也是万维网的支柱。

    # 发起HTTP调用

    Go标准库提供了两种基本方式来发起HTTP调用,以便与网站和Web服务进行交互:如果你不需要配置超时时间、传输属性或重定向策略,只需使用共享客户端。如果你需要进行额外配置,则使用http.Client。本示例将展示这两种方式。

    # 如何操作……

    • 标准库包含一个共享的HTTP客户端。你可以使用它,依据默认配置与网络服务器(web server)进行交互:
    response, err := http.Get("http://example.com")
    if err!=nil {
    	// 处理错误
    }
    
    // 始终关闭响应体
    defer response.Body.Close()
    if response.StatusCode / 100 == 2 {
    	// HTTP 2xx,调用成功。
    	// 处理响应体
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    • 如果你需要设置不同的超时值、更改重定向策略或配置传输(transport),那就创建一个新的http.Client,对其进行初始化,然后使用它:
    client:=http.Client{
        // 为所有传出调用设置超时。
        // 如果调用在30秒内未完成,则超时。
        Timeout: 30*time.Second,
    }
    response, err:=http.Get("http://example.com")
    if err! = nil {
    	// 处理错误
    }
    
    // 始终关闭响应体
    defer response.Body.Close()
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    • 如果操作系统已经有颁发该网站证书的证书颁发机构(CA,Certificate Authority )的证书,那么你就可以使用HTTPS(通过TLS协议)访问网站。互联网上的大多数公共网站都是这种情况:
    response, err := http.Get("https://example.com")
    
    1
    • 如果你使用带有自定义证书颁发机构的TLS协议,或者使用自签名证书,那就必须创建一个带有包含证书的传输(Transport)的http.Client。

      • 创建一个新的证书池:
      roots := x509.NewCertPool()
      
      1
      • 加载服务器证书:
      certData, err := os.ReadFile(*certFile)
      if err != nil {
      	panic(err)
      }
      
      1
      2
      3
      4
      • 将证书添加到证书池:
      ok := roots.AppendCertsFromPEM(certData)
      if !ok {
      	panic("failed to parse root certificate")
      }
      
      1
      2
      3
      4
      • 创建一个TLS配置:
      config: = tls.Config{
          RootCAs: roots,
      }
      
      1
      2
      3
      • 使用TLS配置创建一个HTTP传输:
      transport := &http.Transport {
      	TLSClientConfig: config,
      }
      
      1
      2
      3
      • 创建一个HTTP客户端:
      client := &http.Client{
      	Transport: transport,
      }
      
      1
      2
      3
      • 使用客户端:
      resp, err: = client.Get(url)
      if err != nil {
      	// 处理错误
      }
      
      defer resp.Body.Close()
      
      1
      2
      3
      4
      5
      6

      提示:在处理完响应体后,始终要关闭它,并尽量读取响应体中所有可用的数据。响应体(response.Body)表示与服务器的流式连接。只要有数据在传输,并且客户端保持连接打开,服务器就会为该连接保留资源。这也会阻止客户端重用持久连接(keep-alive connections)。

    # 运行HTTP服务器

    Go标准库提供了一个具有合理默认设置的HTTP服务器,你可以直接使用,这与HTTP客户端的实现方式类似。如果你需要配置传输细节、超时等,那么可以创建一个新的http.Server并使用它。本节将介绍这两种方法。

    # 如何操作……

    1. 创建一个http.Handler来处理HTTP请求:
    func myHandler(w http.ResponseWriter, req *http.Request) {
    	if req.Method == http.MethodGet {
    		// 处理HTTP GET请求
    	}
        
    	...
    }
    
    1
    2
    3
    4
    5
    6
    7
    1. 调用http.ListenAndServe:
    err:=http.ListenAndServe(":8080", http.HandlerFunc(myHandler))
    log.Fatal(err)
    
    1
    2

    ListenAndServe函数要么因设置网络监听器时出错(例如,地址已被使用)而立即返回,要么成功开始监听。当服务器被异步关闭(通过调用server.Close()或server.Shutdown())时,它会返回ErrServerClosed。

    或者,你可以使用http.Server结构体来更好地控制服务器选项:

    1. 如上述描述的那样创建一个http.Handler。
    2. 初始化一个http.Server实例:
    server := http.Server {
        // 监听地址
        Addr: ":8080",
        // 处理函数
        Handler: http.HandlerFunc(myHandler),
        // 处理程序必须在10秒内读取请求
        ReadTimeout: 10*time.Second,
        // 请求头必须在5秒内读取完毕
        ReadHeaderTimeout: 5 * time.Second, 
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    1. 监听HTTP请求:
    err:=server.ListenAndServe()
    log.Fatal(err)
    
    1
    2

    提示:创建HTTP处理程序(HTTP handler)的一种常见方法是使用请求多路复用器(request multiplexer)。使用请求多路复用器的方法将在后面介绍。

    # HTTPS——设置TLS服务器

    要启动一个TLS服务器,你需要一个证书和一个私钥。你可以从证书颁发机构购买,也可以使用内部证书颁发机构生成自己的证书。获得证书后,就可以使用本节中的方法启动HTTPS服务器。

    # 如何操作……

    要创建一个TLS HTTP服务器,可以使用以下方法之一:

    1. 使用Server.ListenAndServeTLS方法,并传入证书和密钥文件:
    server := http.Server { 
        Addr: ":4443",
        Handler: handler,
    }
    
    server.ListenAndServeTLS("cert.pem", "key.pem")
    
    1
    2
    3
    4
    5
    6
    1. 要使用默认的HTTP服务器,设置一个处理函数(或http.Handler),并调用http.ListenAndServeTLS:
    http.HandleFunc("/",func(w http.ResponseWriter, req *http.Request) {
    	// 处理请求
    })
    
    http.ListenAndServeTLS("cert.pem", "key.pem")
    
    1
    2
    3
    4
    5
    1. 或者使用证书准备一个http.Transport:
      • 加载TLS证书:
      cert, err := tls.LoadX509KeyPair("cert.pem", "key.pem")
      if err != nil {
          panic(err)
      }
      
      1
      2
      3
      4
      • 使用证书创建一个tls.Config:
      tlsConfig := &tls.Config{
      Certificates: []tls.Certificate{cert},
      }
      
      1
      2
      3
      • 使用tlsConfig创建一个http.Server:
      server := http.Server{
          Addr:      ":4443",
          Handler:   handler,
          TLSConfig: tlsConfig,
      }
      
      1
      2
      3
      4
      5
      • 调用server.ListenAndServeTLS:
      server.ListenAndServeTLS("", "")
      
      1

    # 编写HTTP处理程序

    当一个HTTP请求到达服务器时,服务器会查看HTTP方法(GET、POST等)、客户端使用的主机名(Host头)和URL,以决定如何处理该请求。这种确定应该由哪个处理程序处理请求的机制被称为请求多路复用器。Go标准库自带了一个请求多路复用器,也有许多第三方开源多路复用器。在本节中,我们将了解标准库多路复用器以及如何使用它。

    # 如何操作……

    1. 对于简单的情况,比如健康检查端点(endpoint),你可以使用匿名函数:
    mux := http.NewServeMux()
    mux.HandleFunc("GET /health",func(w http.ResponseWriter, req *http.Request) {
    	w.Write([]byte("Ok"))
    })
    
    ...
    
    server := http.Server {
        Handler: mux,
        Addr: ":8080",
    	...
    }
    
    server.ListenAndServe()
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14

    上述处理程序将对GET /health端点的请求做出响应,返回“Ok”和HTTP 200状态码。 2. 你可以使用实现了http.Handler接口的数据类型: - 创建一个新的数据类型,它可以是一个结构体,包含实现处理程序所需的信息: go // RandomService从一个源读取随机数据,并返回随机数 type RandomService struct { rndSource io.Reader } - 实现http.Handler接口: ```go func (svc RandomService) ServeHTTP(w http.ResponseWriter, req *http.Request) { // 从随机数源读取4个字节,转换为字符串 data := make([]byte,4) _, err := svc.rndSource.Read(data) if err != nil { // 这将返回一个HTTP 500错误,错误信息作为消息体 http.Error(w, err.Error(), http.StatusInternalServerError) return }

        // 使用二进制小端序编码解码随机数据
        value := binary.LittleEndian.Uint32(data)
        // 将数据写入输出
        w.Write([]byte(strconv.Itoa(int(value))))
    }
    ```
    - 创建处理程序类型的实例并进行初始化:
    ```go
    file, err:=os.Open("/dev/random")
    if err != nil {
    	panic(err)
    }
    
    defer file.Close()
    svc:=RandomService {
    	rndSource: file,
    }
    ```
    
    1. 创建一个多路复用器:
      mux := http.NewServeMux()
      
      1
    2. 将处理程序分配给一个模式。以下示例将对/rnd路径的GET请求分配给在步骤3中构造的实例:
      mux.Handle("GET /rnd", svc)
      
      1
    3. 启动服务器:
      server := http.Server {
          Handler: mux,
          Addr: ":8080",
          ...
      }
      
      server.ListenAndServe()
      
      1
      2
      3
      4
      5
      6
      7
    4. 一种更通用的方法是创建具有多个方法作为处理程序的数据类型。这种模式在Web服务开发中特别有用,因为它允许创建为特定业务领域的所有API提供服务的结构:
      • 创建一个数据类型。它可以是一个结构体,包含实现处理程序所需的所有必要信息,比如数据库连接、公钥/私钥等:
      type UserHandler struct {
      	DB *sql.DB
      }
      
      1
      2
      3
    5. 使用http.HandlerFunc的签名创建方法,以实现多个API端点:
    func (hnd UserHandler) GetUser(w http.ResponseWriter, req *http.Request) {
    	...
    }
    
    1
    2
    3
    1. 创建并初始化处理程序:
    userDb, err:=sql.Open(driver, UserDBUrl)
    if err != nil {
    	panic(err)
    }
    
    userHandler := UserHandler { 
    	DB: userDb,
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    1. 创建一个请求多路复用器:
    mux := http.NewServeMux()
    
    1
    1. 将处理程序方法分配给模式:
    mux.Handle("GET /users/{userId}", userHandler.GetUser)
    mux.Handle("POST /users", userHandler.NewUser)
    mux.Handle("DELETE /users/{userId}", userHandler.DeleteUser)
    
    1
    2
    3
    1. 使用多路复用器启动服务器:
    server := http.Server{ 
        Addr: serverAddr,
        Handler: mux,
    }
    
    server.ListenAndServe()
    
    1
    2
    3
    4
    5
    6

    以下代码片段展示了在编写HTTP处理程序时,如何使用标准库请求多路复用器工具:

    func (hnd UserHandler) GetUser(w http.ResponseWriter, req *http.Request) {
        // 使用req.PathValue("userId")获取/users/{userId}中的userId部分
        // 也就是说,如果使用GET /users/123调用此API,那么在以下这行代码之后,userId将被赋值为"123"
        userId:=req.PathValue("userId")
        // 从数据库获取用户数据
        user, err:=GetUserInformation(hnd.DB,userId)
        if err != nil {
            http.Error(w,err.Error(),http.StatusNotFound)
            return
        }
        
        // 将用户数据编码为JSON
        data, err:=json.Marshal(user)
        if err != nil {
            http.Error(w, err.Error(),http.StatusInternalServerError)
            return
        }
        
        // 设置内容类型头。在写入消息体之前,**必须**设置所有的头信息。一旦消息体被写入,就无法更改已经写入的头信息。
        w.Header().Set("Content-Type","application/json")
        w.Write(data)
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22

    # 在文件系统上提供静态文件服务

    并非所有由Web应用程序提供的文件都是动态生成的。JavaScript文件、层叠样式表(Cascading Style Sheets)和一些HTML页面通常都是原样提供的。本节将展示几种提供此类文件服务的方法。

    # 操作方法……

    通过HTTP提供静态文件服务有多种方式:

    1. 要提供某个目录下的所有静态文件,可以使用http.FileServer创建一个处理器(handler):
    fileHandler := http.FileServer(http.Dir("/var/www"))
    server := http.Server{
        Addr:    addr,
        Handler: fileHandler,
    }
    http.ListenAndServe()
    
    1
    2
    3
    4
    5
    6

    上述代码片段将在根路径下提供/var/www目录下的文件。也就是说,一个GET /index.html请求将提供/var/www/index.html文件,且Content-Type为text/html。同样,一个GET /css/styles.css请求将提供/var/www/css/styles.css文件,且Content-Type为text/css。 2. 要提供某个目录下的所有静态文件,但使用不同的URL路径前缀,可以使用http.StripPrefix:

    fileHandler := http.StripPrefix("/static/", http.FileHandler(http.Dir("/var/www")))
    
    1

    上述调用使用另一个处理器包装给定的文件处理器,该处理器会从URL路径中去除给定的前缀。对于GET /static/index.html请求,这个处理器将提供/var/www/index.html文件,且Content-Type为text/html。如果路径不包含给定的前缀,将返回HTTP 404 Not Found(未找到)错误。 3. 要在URL到文件名的映射中添加额外的逻辑,可以实现http.FileSystem接口,并使用FileServerFS和该文件系统。你可以将这个处理器与http.StripPrefix结合使用,进一步改变URL路径的处理方式:

    // 仅提供给定目录下的HTML文件
    type htmlFS struct {
        fs *http.FileSystem
    }
    
    // 在打开文件前,根据文件扩展名过滤文件名
    func (h htmlFS) Open(name string) (http.File, error) {
        if strings.ToLower(filepath.Ext(name)) == ".html" {
            return h.fs.Open(name)
        }
        return nil, os.ErrNotFound
    }
    
    ...
    
    htmlHandler := http.FileHandler(htmlFS{fs: http.Dir("/var/www")})
    // htmlHandler提供/var/www目录下的所有HTML文件
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17

    # 处理HTML表单

    HTML表单是Web应用程序中捕获数据的重要组件。HTML表单可以在服务器端通过使用Form HTML元素进行处理,也可以在客户端使用JavaScript进行处理。在本节中,我们将介绍如何处理用于服务器端处理的HTTP表单提交。

    # 操作方法……

    在客户端,执行以下操作:

    1. 将数据输入字段包含在Form HTML元素中:
    <form method="POST" action="/auth/login">
        <input type="text" name="userName">
        <input type="password" name="password">
        <button type="submit">Submit</button>
    </form>
    
    1
    2
    3
    4
    5

    这里,method属性决定了HTTP方法,即POST,action属性决定了URL。请注意,这个URL是相对于当前页面URL的。当表单提交时,客户端处理程序将为给定的URL准备一个POST请求,并将输入字段的内容作为名称 - 值对,以application/x-www-form-urlencoded编码方式发送。 在服务器端,执行以下操作:

    • 编写一个处理器来处理POST请求。在处理器中,执行以下操作:
      • 调用http.Request.ParseForm来处理提交的数据。
      • 从http.Request.PostForm获取提交的信息。
      • 处理请求。

    下面的示例使用提交的用户名和密码实现了一个简单的登录场景。该处理器使用一个身份验证器(authenticator)来执行实际的用户验证,如果登录成功,会返回一个cookie。这个cookie包含在后续调用中识别用户的信息:

    type UserHandler struct {
        Auth Authenticator
    }
    
    func (h UserHandler) HandleLogin(w http.ResponseWriter, req *http.Request) {
        // 解析提交的表单。这会将提交的信息填充到req.PostForm中
        if err := req.ParseForm(); err != nil {
            http.Error(w, err.Error(), http.StatusBadRequest)
            return
        }
        
        // 获取提交的字段
        userName := req.PostForm.Get("userName")
        password := req.PostForm.Get("password")
        // 处理登录请求,并获取一个cookie
        cookie, err := h.Auth.Authenticate(userName, password);
        if err != nil {
            // 将用户重定向回登录页面,并设置一个包含错误消息的错误cookie
            http.SetCookie(w, h.NewErrorCookie("Username or password invalid"))
            http.Redirect(w, req, "/login.html", http.StatusFound)
            return
        }
        
        // 设置表示用户会话的cookie
        http.SetCookie(w, cookie)
        // 将用户重定向到主页
        http.Redirect(w, req, "/dashboard.html", http.StatusFound)
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    • 注册处理器以处理特定URL的POST请求:
    userHandler := UserHandler{
        Auth: authenticator,
    }
    mux := http.NewServeMux()
    mux.HandleFunc("POST /auth/login", userHandler.HandleLogin)
    mux.HandleFunc("GET /login.html", userHandler.ShowLoginPage)
    
    1
    2
    3
    4
    5
    6
    提示
    在使用cookie时必须小心。在我们的示例中,cookie由服务器应用程序创建并发送到客户端。随后对服务器的调用将包含该cookie,以便服务器跟踪用户会话。然而,并不能保证客户端提交的cookie是有效的。恶意客户端可能会发送伪造或过期的cookie。可以使用加密方法来确保cookie是由服务器创建的,例如使用密钥对cookie进行签名,或者使用JSON Web Token。
    提示
    前面的示例展示了cookie的另一种用法,即从一个页面向另一个页面发送状态信息。如果登录失败,用户会被重定向到登录页面,并携带一个包含错误消息的cookie。登录页面处理器可以检查这个cookie是否存在,并显示相应的消息。

    这里给出一个示例实现:

    func (h UserHandler) ShowLoginPage(w http.ResponseWriter, req *http.Request) {
        loginFormData := map[string]any{}
        cookie, err := req.Cookie("error_cookie")
        if err == nil {
            loginFormData["error"] = cookie.Value
            // 取消设置cookie
            http.SetCookie(&http.Cookie{
                Name:   "error_cookie",
                MaxAge: 0,
            })
        }
        
        w.Header.Set("Content-Type", "text/html")
        loginFormTemplate.Execute(w, loginFormData)
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15

    NewErrorCookie方法的实现如下:

    func (h UserHandler) NewErrorCookie(msg string) *http.Cookie {
        return &Cookie{
            Name:   "error_cookie",
            Value:  msg,
            MaxAge: 60, // Cookie的有效期为60秒
        }
    }
    
    1
    2
    3
    4
    5
    6
    7

    # 编写用于下载大文件的处理器

    当HTTP客户端请求一个大文件时,通常将所有文件数据加载到内存中再发送给客户端是不可行的。可以使用io.Copy将大文件内容流式传输给客户端。

    # 操作方法……

    可以按照以下方式编写一个用于下载大文件的处理器:

    1. 设置Content-Type头部。
    2. 设置Content-Length头部。
    3. 使用io.Copy写入文件内容。

    这些步骤示例如下:

    func DownloadHandler(w http.ResponseWriter, req *http.Request) {
        fileName := req.PathValue("fileName")
        f, err := os.Open(filepath.Join("/data", fileName))
        if err != nil {
            http.Error(w, err.Error(), http.StatusNotFound)
            return
        }
        
        defer f.Close()
        // TODO
        w.Header.Set("Content-Type", "application/octet-stream")
        w.Header.Set("Content-Length", strconv.Itoa(f.Length()))
        io.Copy(w, f)
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14

    # 以流的方式处理HTTP上传的文件和表单

    标准库提供了处理文件上传的方法。你可以调用http.Request.ParseMultipartForm来处理上传的文件。但这种方法存在一个问题:ParseMultipartForm会处理所有上传的文件,直到达到给定的内存限制。它甚至可能会使用临时文件。如果你要处理大文件,这不是一种可扩展的方法。本节将介绍如何在不创建临时文件或占用大量内存的情况下处理文件上传。

    # 操作方法……

    在客户端,执行以下操作:

    1. 创建一个编码类型为multipart/form-data的HTML表单。
    2. 添加要上传的表单字段和文件。

    示例如下:

    <form action="/upload" method="post" enctype="multipart/form-data">
        <input type="text" name="textField">
        <input type="file" name="fileField">
        <button type="submit">submit</button>
    </form>
    
    1
    2
    3
    4
    5

    提交时,这个表单将创建一个包含两部分的多部分消息:

    • 有一部分的Content-Disposition为form-data; name="textField"。这部分的内容将包含用户在textField输入字段中输入的内容。
    • 还有一部分的Content-Disposition为form-data; name="fileField"; filename=<用户选择的文件名>。这部分的内容将包含文件内容。

    在服务器端,执行以下操作:

    1. 使用http.Request.MultipartReader从请求中获取一个多部分消息体读取器。如果请求不是多部分请求(multipart/mixes或multipart/form-data),这将失败:
    reader, err := request.MultipartReader()
    if err != nil {
        http.Error(w, "Not a multipart request", http.StatusBadRequest)
        return
    }
    
    1
    2
    3
    4
    5
    1. 通过调用MultipartReader.NextPart逐个处理提交数据的各个部分:
    for {
        part, err := reader.NextPart()
        if errors.Is(err, io.EOF) {
            break
        }
        
        if err != nil {
            http.Error(w, err.Error(), http.StatusBadRequest)
            return
        }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    1. 使用Content-Disposition头部检查该部分是表单数据还是文件:
      • 如果Content-Disposition是form-data且没有filename参数,那么这部分包含一个表单字段。
      • 如果Content-Disposition是form-data且有filename参数,那么这部分是一个文件。你可以从消息体中读取文件内容。
    formValues := make(url.Values)
    if fileName := part.FileName(); fileName != "" {
        // 这部分包含一个文件
        output, err := os.Create(fileName)
        if err != nil {
            // 处理错误
        }
        
        defer output.Close()
        if err := io.Copy(output, part); err != nil {
            // 处理错误
        }
    } else if fieldName := part.FormName(); fieldName != "" {
        // 这部分包含某个输入字段的表单数据
        data, err := io.ReadAll(part)
        if err != nil {
            // 处理错误
        }
        
        formValues[fieldName] = append(formValues[fieldName], string(data))
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    第12章 进程
    第14章 流式输入/输出

    ← 第12章 进程 第14章 流式输入/输出→

    最近更新
    01
    第二章 关键字static及其不同用法
    03-27
    02
    第一章 auto与类型推导
    03-27
    03
    第四章 Lambda函数
    03-27
    更多文章>
    Copyright © 2024-2025 沪ICP备2023015129号 张小方 版权所有
    • 跟随系统
    • 浅色模式
    • 深色模式
    • 阅读模式