Last active
September 25, 2025 13:11
-
-
Save ShangjinTang/d68e72a8eb7684a6b242243decc59fc8 to your computer and use it in GitHub Desktop.
Backend Go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 从零开始的后端开发:第一部分——介绍、TCP、DNS、HTTP | |
| _作者:Efron Licht,2023年9月_ | |
| *基于原文完整翻译整理* | |
| ## 目录 | |
| 1. [引言:当我听到"框架"这个词时,我就想掏枪](#引言当我听到框架这个词时我就想掏枪) | |
| 2. [系列概览](#系列概览) | |
| 3. [什么是后端?](#什么是后端) | |
| 4. [构建基本的 TCP/IP 服务器和客户端](#构建基本的-tcpip-服务器和客户端) | |
| 5. [连接到互联网上的服务器](#连接到互联网上的服务器) | |
| 6. [HTTP 请求](#http-请求) | |
| 7. [HTTP 响应](#http-响应) | |
| 8. [构建 HTTP 库](#构建-http-库) | |
| 9. [结论](#结论) | |
| ## 引言:当我听到"框架"这个词时,我就想掏枪 | |
| 新手 Go 开发者最常问的问题之一是:"**我应该使用哪个 Web 框架?**" 我的回答总是:"你不需要框架",但问题在于,后端开发者已经*习惯于*使用框架。 | |
| 思考一下,这种动机是可以理解的:工程师面临很大压力,互联网*看起来*非常复杂,学习所有这些抽象层(TCP、HTTP 等)令人望而生畏,而且*似乎每个人都在使用框架* —— 在大多数语言(JavaScript、Python 等)中,框架几乎是必需的。这样做只有一个问题:**它意味着你永远不会真正了解事物的工作原理**。不断依赖一套专业工具而不去学习基础知识,就像一个高级厨师不会使用刀一样。当然,你可以争论说你的花哨食品加工机切得更快,但当你需要做一些你的预包装工具没有设计的事情时,你就完蛋了;你不知道如何自己做,也没有时间学习。 | |
| 这听起来可能像夸张,但**我现在已经遇到了四个不同的高级软件工程师,他们无法告诉我如何在没有框架的情况下向 Google 发出 HTTP 请求**。 | |
| 记录在案,你向 `142.250.189.14:80` 发送这条消息: | |
| ``` | |
| GET / HTTP/1.1 | |
| Host: google.com | |
| ``` | |
| 就是五个单词。 | |
| 如果你不知道这些东西的含义或我如何获得那个 IP 地址,别担心;我们会讲到的。重点是,这实际上并不难;难的是一万层回调、库、框架、工具、语言、抽象、间接和包装器的包装器。问题是,大多数软件工程师已经习惯了在他们和网络之间有如此多的抽象层,以至于"到达底层"似乎是不可能的。现在,有些人可能会争论说这没关系,因为框架增加的功能和灵活性导致更快、更好的软件开发。唯一的问题是,软件并没有变得更好;它正在变得*可测量地更糟*。[桌面软件](https://www.youtube.com/watch?v=GC-0tCy4P1U&t=2190s)和[网页](https://httparchive.org/reports/state-of-the-web#bytesTotal)都在逐年变得可测量地更慢。软件变慢的速度比计算机加速的速度更快。考虑到这一点,软件**被复杂性淹没**也就不足为奇了。每年,我们添加更多的抽象层、更多的库、更多的框架、更多的工具、更多的语言、更多的一切,但即使我们的"专家"也不理解他们正在构建的东西的基础。难怪软件如此缓慢、有缺陷且难以维护。 | |
| > "愚者赞叹复杂,智者推崇简单" —— Terry A. Davis | |
| 当然,知道事情做得不好并不能帮助你学会如何做*好*,所以我写了这一系列文章,试图通过教授 Go 中后端 Web 开发的基础知识来填补这一空白。每篇文章都将充满你可以在计算机上运行的*真实*程序,而不是那些甚至无法编译的精选代码示例。 | |
| 这个系列不足以教会你一切。充其量,它会让你接触到足够多的事物*真正*工作方式,以便你可以开始看到你知识的边界并开始自己填补空白。它也必然需要简化或省略一些细节或说"善意的谎言"以使事情更容易理解;没有什么可以替代经验(或阅读标准库的源代码和文档)。它也将在很大程度上省略数据库;我希望将其作为未来系列的主题。 | |
| 也就是说,我希望它会有所帮助。 | |
| ## 系列概览 | |
| ### 第一部分:后端基础,第一部分:TCP、DNS 和 HTTP | |
| - 什么是互联网?它解决了哪些问题?什么是 TCP/IP?计算机如何相互通信? | |
| - 什么是 DNS?我们如何将 `www.google.com` 转换为 IP 地址? | |
| - 什么是 HTTP?它是如何工作的?如果不使用库,我们如何手动读取或写入 HTTP 请求或响应? | |
| - 从头开始构建一个请求/响应库。 | |
| ### 第二部分:实践后端:`net/http` 和 `encoding/json` | |
| 在第二篇文章中,我们将开始使用 `net/http` 和 `encoding/json` 包,构建能够处理大多数日常后端工作负载的基本 Web 客户端和服务器。我们将深入研究 Go 的标准库,展示它如何为基本的客户端/服务器 HTTP 通信提供一切所需;使用 `net/http` 和 `net/url` 发送和接收 HTTP 请求和响应,使用 `encoding/json` 管理我们的 API 负载,以及使用 `context` 管理超时和取消。 | |
| ### 第三部分:收尾工作:中间件、路由和基本的数据库访问 | |
| 在第三篇文章中,我们将介绍中间件和路由,这是 `net/http` 包中通常被认为缺失的两部分。这些通常是让人们转向框架的部分,但实际上自己实现它们相当简单。我们还将介绍使用 `database/sql` 包进行基本的数据库访问。 | |
| ## 什么是后端? | |
| "后端"是通过互联网连接计算机。你知道什么是计算机,所以... | |
| ### 什么是互联网? | |
| 什么是互联网?不,我是认真的。互联网解决了什么问题?互联网是一个计算机*网络*,即使中间的一些计算机"宕机",也能可靠地相互通信。它允许你可靠地向其他计算机发送消息(即文本或二进制数据),即使你不知道这些计算机在哪里或它们如何与你连接。你可以向另一台计算机发送消息,只要从你(`LOCALADDR`)到目标计算机(`REMOTEADDR`)之间有一条计算机路径。 | |
| 为此,互联网必须解决两个问题: | |
| - 如何向另一台计算机发送消息,即使我没有直接连接到它?`路由` | |
| - 如何确保当我通过网络发送消息时,它到达正确的地方,按顺序,并且全部传输成功?`一致性` | |
| 这两个问题都由协议解决:**互联网协议**(`IP`)解决了`路由`问题,**传输控制协议**(`TCP`)解决了`一致性`问题。它们合称为`TCP/IP`。这就是互联网的工作原理。 | |
| ### TCP:如何确保通过网络发送的消息完整、有序地到达? | |
| TCP 的细节超出了本文的范围,但从高层次来看,它的工作原理如下: | |
| - 你向远程计算机发送数据包。每个数据包都有一个序列号("这是第几个数据包?")和一个校验和("这个数据包在传输中是否被损坏?")。远程计算机为你发送的每个数据包返回一个确认("我收到了第 5 个数据包")。 | |
| - 如果你没有收到数据包的确认,你会重新发送它;如果你收到一个损坏的数据包,你会重新发送它。 | |
| - 这种来回确认确保所有数据按顺序完整地传输,并且你知道何时未成功传输。 | |
| ### IP:如何确保通过网络发送的消息到达正确的目的地? | |
| IP 更加复杂。以下解释在每个层面上都有错误,但对于我们的目的来说,这是一个足够好的近似: | |
| - 互联网上的每台计算机都有一个`地址`,这是一个标识符,告诉其他计算机如何找到它。这个地址称为`IP 地址`,有时简称为`IP`。 | |
| - 它们还有一个已知的其他计算机及其访问方式的列表。这个列表称为**路由表**。 | |
| - 当你向另一台计算机发送消息时,你的计算机查看其路由表,看看是否知道如何到达那台计算机。如果知道,它将消息发送到链中的下一台计算机。如果不知道,它将消息发送到链中它知道如何到达的下一台计算机;它们继续这样做,直到消息到达正确的计算机。 | |
| - 如果从你的计算机到目标计算机没有路径,消息就会失败。 | |
| ### 地址和端口 | |
| 好的,那么我们实际上如何向另一台计算机发送消息呢?我们需要知道两件事:我们想要发送消息的计算机的`地址`,以及我们想要发送消息的服务的`端口`。 | |
| ### IP 地址 | |
| IP 地址有两种形式:`ipv4`,一个 32 位数字;或 `ipv6`,一个 128 位数字。它们看起来像这样: | |
| IPv4 看起来像这样:DDD.DDD.DDD.DDD,其中 DDD 是 0 到 255 之间的数字。 | |
| IPv6 看起来像这样:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX,其中 XXXX 是一个 16 位十六进制数字;也就是说,每个 X 是 `0..=9` 或 `a..=f` 中的一个。 | |
| | IP 地址 | 类型 | 备注 | | |
| |---------|------|------| | |
| | 192.168.000.001 | ipv4 | localhost;指向主机计算机 | | |
| | 192.168.0.1 | ipv4 | 与上面相同;你可以省略前导零 | | |
| | 0000:0000:0000:0000:0000:ffff:c0a8:0001 | ipv6 | 指向与上面相同的计算机;ipv4 地址可以嵌入到 ipv6 地址中,通过前缀 `::ffff:` | | |
| | ::ffff:c0a8:0001 | ipv6 | 与上面相同;你可以省略前导零 | | |
| | 2a09:8280:1::a:791 | ipv6 | fly.io | | |
| ### 端口 | |
| 计算机通常希望托管多个以不同方式运行的互联网服务。例如,我们可能想要在同一台计算机上托管一个游戏服务器(如 `starcraft`)、一个 Web 服务器(如这个网站)和一个数据库(如 `postgresql`)。由于它们都在同一台物理计算机上,它们将共享一个 IP 地址,所以我们需要某种方式来区分对文件服务器的请求和对游戏服务器的请求。我们通过为每个服务分配一个`端口`来做到这一点。端口只是一个 0 到 65535 之间的数字。即使我们只托管一个服务,每个服务也需要(至少一个)端口。 | |
| `eblog` 托管在端口 6483 上。下表列出了一些常见服务的默认端口: | |
| | 服务 | 端口 | | |
| |------|------| | |
| | HTTP | 80 | | |
| | HTTPS | 443 | | |
| | SSH | 22 | | |
| | SMTP | 25 | | |
| | DNS | 53 | | |
| | FTP | 21 | | |
| | Postgres | 5432 | | |
| ## 构建基本的 TCP/IP 服务器和客户端 | |
| 让我们构建一个基本的 TCP/IP 服务器和客户端来演示这是如何工作的。我们将构建一个在端口 6483 上监听的服务器,以及一个连接到它的客户端。在客户端的 stdin 上发送的任何内容(即输入到终端中的内容)将逐行发送到服务器。服务器接收到的任何行将被转换为大写并发送回客户端。 | |
| 也就是说,示例会话可能如下所示: | |
| ``` | |
| SERVER: (开始在端口 6483 上监听) | |
| CLIENT: (连接到服务器) | |
| CLIENT: "hello, world!" | |
| SERVER: "HELLO, WORLD!" | |
| CLIENT: "goodbye, world!" | |
| SERVER: "GOODBYE, WORLD!" | |
| CLIENT: (断开连接) | |
| ``` | |
| 为了简要回顾,以下函数和类型与我们的示例相关: | |
| | 函数/结构体 | 描述 | 实现 | | |
| |------------|------|------| | |
| | `net.Listen` | 在端口上监听连接 | | | |
| | `net.Dial` | 连接到 IP 地址和端口的服务器 | | | |
| | `net.TCPConn` | 双向 TCP 连接 | `io.Reader`, `io.Writer`, `net.Conn` | | |
| | `net.Conn` | 双向网络连接 | `io.Reader`, `io.Writer` | | |
| | `bufio.Scanner` | 从 `io.Reader` 读取行 | | | |
| | `fmt.Fprintf` | 如 `fmt.Printf`,但写入 `io.Writer` | | | |
| | `flag.Int` | 注册一个整数命令行标志 | | | |
| | `flag.Parse` | 解析先前注册的命令行标志 | | | |
| | `log.Printf` | 如 `fmt.Fprintf(os.Stderr, ...)`,但带有时间戳和换行符 | | | |
| | `log.Fatalf` | 如 `log.Printf`,但在打印后调用 `os.Exit(1)` | | | |
| ### 客户端 | |
| 让我们先写客户端:我们称它为 `writetcp`。 | |
| ```go | |
| // writetcp 连接到 localhost 上指定端口的 TCP 服务器(默认 8080),并将 stdin 逐行转发到服务器, | |
| // 直到达到 EOF。 | |
| // 从服务器接收的行将打印到 stdout。 | |
| package main | |
| import ( | |
| "bufio" | |
| "flag" | |
| "fmt" | |
| "log" | |
| "net" | |
| "os" | |
| ) | |
| func main() { | |
| const name = "writetcp" | |
| log.SetPrefix(name + "\t") | |
| // 注册命令行标志:-p 指定要连接的端口 | |
| port := flag.Int("p", 8080, "port to connect to") | |
| flag.Parse() // 解析注册的标志 | |
| conn, err := net.DialTCP("tcp", nil, &net.TCPAddr{Port: *port}) | |
| if err != nil { | |
| log.Fatalf("error connecting to localhost:%d: %v", *port, err) | |
| } | |
| log.Printf("connected to %s: will forward stdin", conn.RemoteAddr()) | |
| defer conn.Close() | |
| go func() { // 生成一个 goroutine 来读取来自服务器的传入行并将它们打印到 stdout。 | |
| // TCP 是全双工的,所以我们可以同时读取和写入;我们只需要生成一个 goroutine 来进行读取。 | |
| for connScanner := bufio.NewScanner(conn); connScanner.Scan(); { | |
| fmt.Printf("%s\n", connScanner.Text()) // 注意:printf 不添加换行符,所以我们需要自己添加 | |
| if err := connScanner.Err(); err != nil { | |
| log.Fatalf("error reading from %s: %v", conn.RemoteAddr(), err) | |
| } | |
| } | |
| }() | |
| // 从 stdin 读取传入的行并将它们转发到服务器。 | |
| for stdinScanner := bufio.NewScanner(os.Stdin); stdinScanner.Scan(); { // 在 stdin 中找到下一个换行符 | |
| log.Printf("sent: %s\n", stdinScanner.Text()) | |
| if _, err := conn.Write(stdinScanner.Bytes()); err != nil { // scanner.Bytes() 返回一个字节切片,直到但不包括下一个换行符 | |
| log.Fatalf("error writing to %s: %v", conn.RemoteAddr(), err) | |
| } | |
| if _, err := conn.Write([]byte("\n")); err != nil { // 我们需要重新添加换行符 | |
| log.Fatalf("error writing to %s: %v", conn.RemoteAddr(), err) | |
| } | |
| if stdinScanner.Err() != nil { | |
| log.Fatalf("error reading from %s: %v", conn.RemoteAddr(), err) | |
| } | |
| } | |
| } | |
| ``` | |
| ### 服务器 | |
| 现在让我们组装服务器;由于它回显它接收到的内容,以大写形式,我们称它为 `tcpupperecho`。 | |
| 通常在后端工作时,我们希望将"业务逻辑"与网络代码分开。由于 Go 的所有网络 API 都使用 [net.Conn](https://golang.org/pkg/net/#Conn) 接口,该接口实现了 [io.Reader](https://golang.org/pkg/io/#Reader) 和 [io.Writer](https://golang.org/pkg/io/#Writer),我们可以使用标准的文本处理函数和结构体(如 [fmt.Fprintf](https://golang.org/pkg/fmt/#Fprintf) 和 [bufio.Scanner](https://golang.org/pkg/bufio/#Scanner))编写我们的业务逻辑。 | |
| 我们服务器的"业务逻辑"将如下所示: | |
| ```go | |
| // echoUpper 从 r 读取行,将它们转换为大写,并将它们写入 w。 | |
| func echoUpper(w io.Writer, r io.Reader) { | |
| scanner := bufio.NewScanner(r) | |
| for scanner.Scan() { | |
| line := scanner.Text() | |
| // 注意 scanner.Text() 从行末去除换行符, | |
| // 所以当我们写入 w 时,我们需要重新添加它。 | |
| fmt.Fprintf(w, "%s\n", strings.ToUpper(line)) | |
| } | |
| if err := scanner.Err(); err != nil { | |
| log.Printf("error: %s", err) | |
| } | |
| } | |
| ``` | |
| 然后我们可以在服务器中这样使用它: | |
| ```go | |
| // tcpupperecho 在端口 8080 上提供 tcp 连接,逐行从每个连接读取并将每行的大写版本写回客户端。 | |
| package main | |
| import ( | |
| "bufio" | |
| "flag" | |
| "fmt" | |
| "io" | |
| "log" | |
| "net" | |
| "strings" | |
| ) | |
| func main() { | |
| const name = "tcpupperecho" | |
| log.SetPrefix(name + "\t") | |
| // 构建命令行接口;详情请参见 https://golang.org/pkg/flag/。 | |
| port := flag.Int("p", 8080, "port to listen on") | |
| flag.Parse() | |
| // ListenTCP 创建一个 TCP 监听器,接受给定地址上的连接。 | |
| // TCPAddr 表示 TCP 端点的地址;它有一个 IP、端口和区域,所有这些都是可选的。 | |
| // 区域仅对 IPv6 重要;我们现在忽略它。 | |
| // 如果我们省略 IP,意味着我们在所有可用的 IP 地址上监听;如果我们省略端口,意味着我们在随机端口上监听。 | |
| // 我们想要在用户在命令行上指定的端口上监听。 | |
| // 详情请参见 https://golang.org/pkg/net/#ListenTCP 和 https://golang.org/pkg/net/#Dial。 | |
| listener, err := net.ListenTCP("tcp", &net.TCPAddr{Port: *port}) | |
| if err != nil { | |
| panic(err) | |
| } | |
| defer listener.Close() // 当我们退出 main() 时关闭监听器 | |
| log.Printf("listening at localhost: %s", listener.Addr()) | |
| for { // 永远循环,一次接受一个连接 | |
| // Accept() 阻塞直到建立连接,然后返回表示连接的 Conn。 | |
| conn, err := listener.Accept() | |
| if err != nil { | |
| panic(err) | |
| } | |
| go echoUpper(conn, conn) // 生成一个 goroutine 来处理连接 | |
| } | |
| } | |
| ``` | |
| ### 演示 | |
| 让我们试试看。在一个终端中,我们运行服务器: | |
| 输入: | |
| ``` | |
| go build -o tcpupperecho ./tcpupperecho.go | |
| ./tcpupperecho -p 8080 # 运行服务器,在端口 8080 上监听 | |
| ``` | |
| 输出: | |
| ``` | |
| tcpupperecho 2023/09/07 10:13:13 listening at localhost: [::]:8080 | |
| ``` | |
| 让我们在另一个终端中运行客户端并向它发送消息: | |
| ``` | |
| $ go build -o writetcp ./writetcp.go | |
| $ ./writetcp -p 8080 # 运行客户端,连接到 localhost:8080 | |
| > writetcp 2023/09/07 10:20:32 connected to 127.0.0.1:8080: will forward stdin | |
| hello | |
| writetcp 2023/09/07 10:20:49 sent: hello | |
| HELLO | |
| ``` | |
| 回到服务器终端,我们看到: | |
| ``` | |
| tcpupperecho 2023/09/07 10:20:49 received: hello | |
| ``` | |
| ## 连接到互联网上的服务器 | |
| 这对本地地址工作得很好,但如果我们想要连接到互联网上的服务器呢?大多数时候,我们不知道我们想要连接的服务器的 `IP` 地址;我们只知道它的`域名`,如 `google.com` 或 `eblog.fly.dev`。我们如何连接到域名的服务器? | |
| ### DNS | |
| **域名服务**或 `DNS` 是一个将域名映射到 IP 地址的服务。它本质上是一个大表,看起来像这样: | |
| | 域名 | 最后已知的 ipv4 | 最后已知的 ipv6 | | |
| |------|----------------|----------------| | |
| | google.com | 142.250.217.142 | 2607:f8b0:4007:801::200e | | |
| | eblog.fly.dev | 66.241.125.53 | 2a09:8280:1::37:6bbc | | |
| 有多个 `DNS` 提供商。你的 ISP 通常提供一个,还有像 Google 的公共 DNS 服务器,可在 `8.8.8.8` 和 `4.4.4.4` 获得。(由于你无法在不知道 DNS 服务器的 IP 地址的情况下解析域名,你需要"心中"知道至少一个 IP 才能开始。) | |
| 浏览器和其他客户端使用 `DNS` 服务来查找域名的 IP 地址。 | |
| ### 查找服务器的 IP 地址 | |
| 好的,所以我们想要连接到一个**网址**的服务器:比如说,`https://eblog.fly.dev`。我们如何做到这一点?首先,我们需要获取服务器的 IP 地址。**域名服务**或 `DNS` 是一个将域名映射到 IP 地址的服务。你可以在 Windows、Mac 或 Linux 上使用内置的 `nslookup` 命令从命令行查找域名的 IP 地址。 | |
| 输入: | |
| ``` | |
| nslookup eblog.fly.dev | |
| ``` | |
| 输出: | |
| ``` | |
| Server: UnKnown | |
| Address: 192.168.1.1 | |
| Non-authoritative answer: | |
| Name: eblog.fly.dev | |
| Addresses: 2a09:8280:1::37:6bbc | |
| 66.241.125.53 | |
| ``` | |
| 在 Go 程序中,使用 [`net.LookupIP`](https://golang.org/pkg/net/#LookupIP) 查找域名的 IP 地址。以下完整程序复制了 `nslookup` 的功能: | |
| ```go | |
| // dns 是一个简单的命令行工具,用于查找主机的 ip 地址; | |
| // 它打印找到的第一个 ipv4 和 ipv6 地址,如果找不到则打印"none"。 | |
| package main | |
| import ( | |
| "fmt" | |
| "log" | |
| "net" | |
| "os" | |
| ) | |
| func main() { | |
| if len(os.Args) != 2 { | |
| log.Printf("%s: usage: <host>", os.Args[0]) | |
| log.Fatalf("expected exactly one argument; got %d", len(os.Args)-1) | |
| } | |
| host := os.Args[1] | |
| ips, err := net.LookupIP(host) | |
| if err != nil { | |
| log.Fatalf("lookup ip: %s: %v", host, err) | |
| } | |
| if len(ips) == 0 { | |
| log.Fatalf("no ips found for %s", host) // 这应该永远不会发生,但以防万一 | |
| } | |
| // 打印我们找到的第一个 ipv4 | |
| for _, ip := range ips { | |
| if ip.To4() != nil { | |
| fmt.Println(ip) | |
| goto IPV6 // goto 被认为是很棒的 | |
| } | |
| } | |
| fmt.Printf("none\n") // 只有在我们找不到任何 ipv4 地址时才打印"none" | |
| IPV6: // 打印我们找到的第一个 ipv6 | |
| for _, ip := range ips { | |
| if ip.To4() == nil { | |
| fmt.Println(ip) // 我们不需要在这里检查 nil,因为我们知道我们至少有一个 ip 地址 | |
| return | |
| } | |
| } | |
| fmt.Printf("none\n") | |
| } | |
| ``` | |
| 输入: | |
| ``` | |
| go build -o dns ./dns.go # 构建 dns 命令 | |
| ./dns eblog.fly.dev # 运行 dns 命令 | |
| ``` | |
| 输出: | |
| ``` | |
| 66.241.125.53 | |
| 2a09:8280:1::37:6bbc | |
| ``` | |
| ### 综合运用:`DNS` 和 `HTTP` | |
| 我们现在拥有了互联网浏览基础所需的一切:我们可以查找域名的 IP 地址,我们可以连接到 IP 地址和端口的服务器。 | |
| 当你在浏览器中输入 URL 时,它执行以下操作: | |
| - 查找域名的 IP 地址 | |
| - 连接到该 IP 地址和端口的服务器 | |
| - 向服务器发送 `HTTP` 请求 | |
| - 读取并显示 `HTTP` 响应 - 通常是一个网页。 | |
| 但是等等,什么是 HTTP?**超文本传输协议**是一个用于通过互联网发送消息的**基于文本的**协议。 | |
| HTTP 并不像它看起来那么可怕。让我们从请求开始。 | |
| ## HTTP 请求 | |
| HTTP 请求是纯文本,看起来像这样: | |
| ``` | |
| <METHOD> <PATH> <PROTOCOL/VERSION> | |
| Host: <HOST> | |
| [<HEADER>: <VALUE>] | |
| [<HEADER>: <VALUE>] | |
| [<HEADER>: <VALUE>] (这些都是可选的) | |
| [<REQUEST BODY>] (这也是可选的)。 | |
| ``` | |
| 为了给出一个更具体的例子,你可以发送来获取这个网页的最基本的 HTTP 请求看起来像这样: | |
| ``` | |
| GET /backendbasics.html HTTP/1.1 | |
| Host: eblog.fly.dev | |
| ``` | |
| (这里有几个陷阱:换行符是 Windows 风格的 `\r\n`,不是 Unix 风格的 `\n`;请求必须以空行结束。) | |
| 让我们分解一下。我们可以这样理解: | |
| - **GET** 主机 `eblog.fly.dev` 上的资源 | |
| - 在路径 `/backendbasics.html` | |
| - 使用 **HTTP/1.1** 协议。 | |
| 第一行是**请求行**。它有三个部分: | |
| - **METHOD**(如 `GET`、`POST`、`PUT`、`DELETE` 等)告诉服务器这是什么类型的请求。现在,我们只关心两个:`GET` 意味着"读取",`POST` 意味着"写入"。 | |
| - **PATH** 是你想要访问的资源的路径;这是网址中 `.com` 或 `.dev` 之后的部分。这里,**PATH** 是 `/backendbasics.html` | |
| - **PROTOCOL/VERSION** 是请求的协议和版本;几乎总是 `HTTP/1.1` 或 `HTTP/2.0` | |
| **请求行**后面跟着一个或多个**头部**。 | |
| **头部**是一个键值对,用冒号(`:`)分隔。**键**应该格式化为 `Title-Case`,**值**应该格式化为 `lower-case`;例如,`Content-Type: application/json`。一些头部在 HTTP 规范中有官方含义,但大多数只是对服务器如何处理请求的建议。从技术上讲,头部是 [MIME](https://en.wikipedia.org/wiki/MIME) 头部,但我们已经有足够多的缩写要处理了;我们现在只称它们为头部。 | |
| **HOST** 头部是必需的;它告诉服务器你试图访问哪个域名。对于这篇文章,**HOST** 头部是 `Host: eblog.fly.dev`。其他头部是可选的,可以用来向服务器发送额外的信息。一些常见的头部包括: | |
| | 头部 | 描述 | 示例 | | |
| |------|------|------| | |
| | `Accept-Encoding` | 我可以接受用这些编码编码的响应 | `gzip`, `deflate` | | |
| | `Accept` | 客户端可以接受的响应类型 | `text/html` | | |
| | `Cache-Control` | 客户端希望服务器如何缓存响应 | `no-cache` | | |
| | `Content-Encoding` | 我的响应体使用以下编码 | `gzip`, `deflate` | | |
| | `Content-Length` | 我的体长度为 N 字节 | 47 | | |
| | `Content-Type` | 请求体的类型 | `application/json` | | |
| | `Date` | 请求的日期和时间 | `Tue, 17 Aug 2021 23:00:00 GMT` | | |
| | `Host` | 你试图访问的服务器的域名 | `eblog.fly.dev` | | |
| | `User-Agent` | 发出请求的客户端的名称和版本 | `curl/7.64.1`, `Mozilla/5.0 (Linux; Android 8.0.0; SM-G955U Build/R16NW)` | | |
| 你的浏览器发送的头部比这多得多:你可以通过打开开发者工具并查看网络选项卡来查看它们。 | |
| 这是当我在开发工具网络选项卡上打开此页面时 Chrome 发送的内容(即,当我向 `https://eblog.fly.dev/backendbasics.html` 发送 `GET` 请求时): | |
| ``` | |
| GET / HTTP/1.1 | |
| Host: eblog.fly.dev | |
| Accept-Encoding: gzip, deflate, br | |
| Accept-Language: en-US,en;q=0.9 | |
| Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7 | |
| Cache-Control: no-cache | |
| Pragma: no-cache | |
| Sec-Ch-Ua-Mobile: ?1 | |
| Sec-Ch-Ua-Platform: "Android" | |
| Sec-Ch-Ua: "Chromium";v="116", "Not)A;Brand";v="24", "Google Chrome";v="116" | |
| Sec-Fetch-Dest: document | |
| Sec-Fetch-Mode: navigate | |
| Sec-Fetch-Site: none | |
| Sec-Fetch-User: ?1 | |
| Upgrade-Insecure-Requests: 1 | |
| User-Agent: Mozilla/5.0 (Linux; Android 8.0.0; SM-G955U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Mobile Safari/537.36 | |
| ``` | |
| 可以有多个具有相同键的头部;例如,你可能有多个 `Accept-Encoding` 头部,每个都有不同的编码。或者,你可以用逗号分隔多个值。也就是说,这两个应该是等价的: | |
| ``` | |
| Accept-Encoding: gzip | |
| Accept-Encoding: deflate | |
| ``` | |
| 和 | |
| ``` | |
| Accept-Encoding: gzip, deflate | |
| ``` | |
| 服务器通常会选择它理解的第一个。服务器*应该*将键视为不区分大小写,但在实践中这并不总是如此;类似地,一些 Web 服务器不能正确处理具有相同键的多个头部。 | |
| ### URL 编码 | |
| 注意 HTTP 请求使用以下字符分隔其部分:`' '`、`'\r'`、`'\n'`、`:`。这意味着我们不能在请求行或头部中使用这些字符而不混淆服务器;它不知道我们是试图分隔一个部分还是给它字面文本。 | |
| 因此,URL 路径和头部不能包含这些字符;我们必须在将它们发送到服务器之前使用 [URL %-编码](https://en.wikipedia.org/wiki/Percent-encoding) "转义"它们。URL 编码实际上非常简单:取任何 ASCII 字符,将其转换为十六进制的值,前缀为 `%`。例如,空格字符在十六进制中是 `0x20`,所以我们将其编码为 `%20`。百分比字符本身是 `0x25`,所以我们将其编码为 `%25`。 | |
| 以下字符可以在 URL 路径或头部中使用而无需转义: | |
| | 类别 | 字符 | | |
| |------|------| | |
| | 小写 ascii 字母 | `abcdefghijklmnopqrstuvwxyz` | | |
| | 大写 ascii 字母 | `ABCDEFGHIJKLMNOPQRSTUVWXYZ` | | |
| | 数字 | `0123456789` | | |
| | 未保留字符 | `-._~` | | |
| | 转义 | `%` 后跟两个十六进制数字 | | |
| 但一些字符只能在某些上下文中不转义使用: | |
| | 字符集 | 上下文 | 注意 | | |
| |--------|--------|------| | |
| | `:/?#[]@` | 路径 | 我从未见过 `[]`;`@` 用于身份验证 | | |
| | `&` | 查询参数 | 分隔查询参数 | | |
| | `+` | 查询参数 | 用于在查询参数中编码空格 | | |
| | `=` | 查询参数 | 在查询参数中分隔键和值 | | |
| | `;` | 路径 | 分隔路径段;很少使用 | | |
| | `$` | 路径 | 很少使用 | | |
| 其他所有内容都必须转义。例如,以下请求路径是有效的: | |
| ``` | |
| GET /backendbasics.html HTTP/1.1 | |
| Host: eblog.fly.dev | |
| ``` | |
| 但这个不是: | |
| ``` | |
| GET /backend basics.html HTTP/1.1 | |
| Host: eblog.fly.dev | |
| ``` | |
| 应该编码为: | |
| ``` | |
| GET /backend%20basics.html HTTP/1.1 | |
| Host: eblog.fly.dev | |
| ``` | |
| 标准库中的 [`url.PathEscape`](https://golang.org/pkg/net/url/#PathEscape) 和 [`url.PathUnescape`](https://golang.org/pkg/net/url/#PathUnescape) 函数可用于转义和反转义字符串以在 URL 路径或头部中使用;我们将在后面的文章中更详细地介绍该包。 | |
| ### 查询参数 | |
| PATH 还可以包含**查询参数**;这些是形式为 `key=value` 的键值对,跟在路径后面。你用 `?` 结束路径的"正常"部分,然后添加查询参数,用 `&` 分隔每个。 | |
| 如果我想要进行 Google 搜索"backend_basics",我会发送以下请求: | |
| ``` | |
| GET /search?q=backend_basics HTTP/1.1 | |
| Host: google.com | |
| ``` | |
| 这有一个查询参数,**键** `q` 和**值** `backend_basics`。我可以通过用 `&` 分隔来添加额外的查询参数: | |
| **[scryfall](https://scryfall.com/)** API 允许你使用各种查询参数搜索魔法卡:如果我想搜索名称中包含"ice"一词的卡片,按发布日期排序,我会发送以下请求: | |
| ``` | |
| GET /search?q=ice&order=released&dir=asc HTTP/1.1 | |
| ``` | |
| 这将有三个查询参数:"q=ice"、"order=released"和"dir=asc"。注意 `=` 和 `&` 字符在查询参数中没有转义。 | |
| 这就是 HTTP 请求的全部内容。让我们尝试使用 TCP 向 `eblog.fly.dev` 发送 `HTTP` 请求。以下完整程序 `sendreq` 向给定主机、端口和路径的服务器发送 HTTP 请求,并将响应打印到 stdout。 | |
| ### 使用 `sendreq.go` 发送 HTTP 请求 | |
| ```go | |
| // sendreq 向指定的主机、端口和路径发送请求,并将响应打印到 stdout。 | |
| // 标志:-host, -port, -path, -method | |
| package main | |
| import ( | |
| "bufio" | |
| "flag" | |
| "fmt" | |
| "log" | |
| "net" | |
| "os" | |
| "strings" | |
| ) | |
| // 定义标志 | |
| var ( | |
| host, path, method string | |
| port int | |
| ) | |
| func main() { | |
| // 初始化和解析标志 | |
| flag.StringVar(&method, "method", "GET", "HTTP method to use") | |
| flag.StringVar(&host, "host", "localhost", "host to connect to") | |
| flag.IntVar(&port, "port", 8080, "port to connect to") | |
| flag.StringVar(&path, "path", "/", "path to request") | |
| flag.Parse() | |
| // ResolveTCPAddr 是创建 TCPAddr 的一种稍微更方便的方式。 | |
| // 现在我们知道如何使用 net.LookupIP 手动执行此操作,我们可以改用这个。 | |
| ip, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("%s:%d", host, port)) | |
| if err != nil { | |
| panic(err) | |
| } | |
| // 使用我们刚创建的 TCPAddr 拨号远程主机... | |
| conn, err := net.DialTCP("tcp", nil, ip) | |
| if err != nil { | |
| panic(err) | |
| } | |
| log.Printf("connected to %s (@ %s)", host, conn.RemoteAddr()) | |
| defer conn.Close() | |
| var reqfields = []string{ | |
| fmt.Sprintf("%s %s HTTP/1.1", method, path), | |
| "Host: " + host, | |
| "User-Agent: httpget", | |
| "", // 空行以终止头部 | |
| // 如果我们有体,它会在这里 | |
| } | |
| // 例如,对于对 http://eblog.fly.dev/ 的请求 | |
| // GET / HTTP/1.1 | |
| // Host: eblog.fly.dev | |
| // User-Agent: httpget | |
| // | |
| request := strings.Join(reqfields, "\r\n") + "\r\n" // 注意 Windows 风格的行结束符 | |
| conn.Write([]byte(request)) | |
| log.Printf("sent request:\n%s", request) | |
| for scanner := bufio.NewScanner(conn); scanner.Scan(); { | |
| line := scanner.Bytes() | |
| if _, err := fmt.Fprintf(os.Stdout, "%s\n", line); err != nil { | |
| log.Printf("error writing to connection: %s", err) | |
| } | |
| if scanner.Err() != nil { | |
| log.Printf("error reading from connection: %s", err) | |
| return | |
| } | |
| } | |
| } | |
| ``` | |
| 让我们在这个博客的索引页面上试试(在 localhost:8080 上运行): | |
| ``` | |
| go build -o sendreq ./sendreq.go | |
| ./sendreq -host eblog.fly.dev -port 8080 | |
| ``` | |
| ``` | |
| 2023/09/07 13:59:19 connected to localhost (@ 127.0.0.1:8080) | |
| 2023/09/07 13:59:19 sent request: | |
| ``` | |
| ``` | |
| GET / HTTP/1.1 | |
| Host: localhost | |
| User-Agent: httpget | |
| ``` | |
| 我们得到一个响应,一个到 `/index.html` 的*重定向*: | |
| ``` | |
| HTTP/1.1 308 Permanent Redirect | |
| Content-Type: text/html; charset=utf-8 | |
| E-Req-Id: b641130b240142ae82ae8b122c35c80f | |
| E-Trace-Id: 086e9e55-364b-4cfd-b8fe-6497214af367 | |
| Location: /index.html | |
| Date: Thu, 07 Sep 2023 20:59:19 GMT | |
| Content-Length: 47 | |
| <a href="/index.html">Permanent Redirect</a>. | |
| ``` | |
| 我们将在下一节中检查 HTTP 响应。 | |
| ## HTTP 响应 | |
| **HTTP** 响应也是纯文本,看起来像这样: | |
| ```http | |
| <PROTOCOL/VERSION> <STATUS CODE> <STATUS MESSAGE> | |
| [<HEADER>: <VALUE>] (这些都是可选的) | |
| [<HEADER>: <VALUE>] | |
| [<HEADER>: <VALUE>] | |
| [<RESPONSE BODY>] (这是可选的)。 | |
| ``` | |
| 第一行是**状态行**。它有三个部分: | |
| - **PROTOCOL/VERSION** 是响应的协议和版本;它应该总是与请求相同。 | |
| - **STATUS CODE** 是一个三位数字,告诉你请求是成功还是失败。第一位数字告诉你响应的一般类别: | |
| + `1xx` 表示"信息性"。这些不经常使用。 | |
| + `2xx` 表示"成功"。**200 OK** 和 **201** created 是你在实践中唯一会看到的。 | |
| + `3xx` 表示"重定向"。**301** 和 **308** 是你在实践中唯一会看到的。 | |
| + `4xx` 表示"客户端错误";你可能熟悉 **404 Not Found** 和 **403 Forbidden**,但还有很多其他的。 | |
| + `5xx` 表示"服务器错误"。**500 Internal Server Error** 是你在实践中唯一会看到的;它是任何未处理错误的默认错误代码。 | |
| 每个状态代码都有一个对应的 **STATUS MESSAGE**;例如,**200 OK** 或 **404 Not Found**。状态消息只是状态代码的人类可读描述。 | |
| 头部的工作方式与请求中的相同:它们是用冒号(`:`)分隔的键值对,键应该格式化为 `Title-Case`,值应该格式化为 `lower-case`。响应的头部通常与请求的头部"对称";如果你发送 `Accept-Encoding: gzip` 头部,你通常会得到 `Content-Encoding: gzip` 头部作为响应。 | |
| 最后一部分是**响应体**。在这里,它是一些 **HTML**,告诉浏览器重定向到 `/index.html`。别担心,我不会涵盖 HTML:这是一篇后端文章,不是前端文章。 | |
| 让我们跟随重定向并请求 `/index.html`: | |
| ```bash | |
| ./sendreq -host eblog.fly.dev -port 8080 -path /index.html | |
| ``` | |
| 我们得到一个 200 OK 响应和索引页面的(非常稀疏的)内容: | |
| ```http | |
| HTTP/1.1 200 OK | |
| E-Req-Id: 47cf0abba4fd4629a9a926769649f653 | |
| E-Trace-Id: dc2c9528-0322-4a16-8688-8ce760fff374 | |
| Date: Thu, 07 Sep 2023 21:04:28 GMT | |
| Content-Length: 1300 | |
| Content-Type: text/html; charset=utf-8 | |
| <!DOCTYPE html><html><head> | |
| <title>index.html</title> | |
| <meta charset="utf-8"/> | |
| <link rel="stylesheet" type="text/css" href="/dark.css"/> | |
| </head> | |
| <body> | |
| <h1> articles </h1> | |
| <h4><a href="/performanceanxiety.html">performanceanxiety.html</a> | |
| </h4><h4><a href="/onoff.html">onoff.html</a> | |
| </h4><h4><a href="/fastdocker.html">fastdocker.html</a> | |
| </h4><h4><a href="/README.html">README.html</a> | |
| </h4><h4><a href="/mermaid_test.html">mermaid_test.html</a> | |
| </h4><h4><a href="/quirks3.html">quirks3.html</a> | |
| </h4><h4><a href="/console-autocomplete.html">console-autocomplete.html</a> | |
| </h4><h4><a href="/console.html">console.html</a> | |
| </h4><h4><a href="/cheatsheet.html">cheatsheet.html</a> | |
| </h4><h4><a href="/testfast.html">testfast.html</a> | |
| </h4><h4><a href="/quirks2.html">quirks2.html</a> | |
| </h4><h4><a href="/bytehacking.html">bytehacking.html</a> | |
| </h4><h4><a href="/benchmark_results.html">benchmark_results.html</a> | |
| </h4><h4><a href="/index.html">index.html</a> | |
| </h4><h4><a href="/noframework.html">noframework.html</a> | |
| </h4><h4><a href="/faststack.html">faststack.html</a> | |
| </h4><h4><a href="/backendbasics.html">backendbasics.html</a> | |
| </h4><h4><a href="/startfast.html">startfast.html</a> | |
| </h4><h4><a href="/quirks.html">quirks.html</a> | |
| </h4><h4><a href="/reflect.html">reflect.html</a> | |
| ``` | |
| 这还可以,但处理原始 HTTP 请求和响应有点痛苦。在我们深入 Go 的 `net/http` 包之前,让我们想想如何自己实现一个 HTTP 库。 | |
| ## 构建 HTTP 库 | |
| 我们希望有一种编写请求和响应的方法,而不必担心协议的细节,比如确保我们的换行符是 Windows 风格的 `\r\n` 而不是 Unix 风格的 `\n`,或者标题化我们的头部。 | |
| 也就是说,我们需要四样东西,大致按难度顺序排列(最简单的优先): | |
| - 一种在内存中表示 HTTP 请求或响应的方法 | |
| - 一种向请求或响应添加头部的方法 | |
| - 一种将它们序列化为 HTTP 格式文本的方法 | |
| - 一种从 HTTP 格式文本解析它们的方法 | |
| ### 请求和响应结构体 | |
| 限制我们现在只处理 HTTP 1.1,我们可以将 HTTP 请求视为具有以下字段的结构体: | |
| ```go | |
| // Header 表示一个 HTTP 头部。HTTP 头部是一个键值对,用冒号 (:) 分隔; | |
| // 键应该格式化为 Title-Case。 | |
| // 使用 Request.AddHeader() 或 Response.AddHeader() 向请求或响应添加头部并保证键的标题化。 | |
| type Header struct {Key, Value string} | |
| // Request 表示一个 HTTP 1.1 请求。 | |
| type Request struct { | |
| Method string // 例如,GET, POST, PUT, DELETE | |
| Path string // 例如,/index.html | |
| Headers []Header // 例如,Host: eblog.fly.dev | |
| Body string // 例如,<html><body><h1>hello, world!</h1></body></html> | |
| } | |
| ``` | |
| 和一个具有以下字段的 HTTP 响应结构体: | |
| ```go | |
| type Response struct { | |
| StatusCode int // 例如,200 | |
| Headers []Header // 例如,Content-Type: text/html | |
| Body string // 例如,<html><body><h1>hello, world!</h1></body></html> | |
| } | |
| ``` | |
| ## 结论 | |
| 你很少会直接解析 HTTP,但当事情出错时,知道它们实际上是如何工作的很重要。协议的相对简单性应该在你将其与现代 Web 的令人难以置信的过度工程复杂性进行比较时引起一些关注。在下一篇文章中,我们将开始深入了解如何"真正"处理 HTTP,并深入研究标准库的 `net/http` 包。 | |
| --- | |
| *本指南基于 Efron Licht 的后端开发系列教程完整翻译整理,保留了原文的所有技术细节、代码示例和实践演示。适合希望深入理解后端开发原理的开发者学习参考。* | |
| ## 学习建议 | |
| ### 1. 渐进式学习 | |
| - 从简单的 TCP Echo 服务器开始 | |
| - 逐步添加 HTTP 功能 | |
| - 每个概念都要有可运行的代码验证 | |
| ### 2. 深入理解原理 | |
| - 阅读标准库源码 | |
| - 理解接口设计思想 | |
| - 掌握错误处理模式 | |
| ### 3. 实践项目推荐 | |
| 1. TCP Echo 服务器 | |
| 2. HTTP 请求工具 | |
| 3. 简单 Web 服务器 | |
| 4. HTTP 代理服务器 | |
| 5. RESTful API 服务 | |
| ### 4. 调试技巧 | |
| - 使用网络抓包工具 | |
| - 查看浏览器开发者工具 | |
| - 理解协议细节 | |
| ## 核心理念 | |
| **简单性胜过复杂性:** | |
| - HTTP 协议本身非常简单 | |
| - 现代 Web 的复杂性往往是人为造成的 | |
| - 理解基础原理比掌握框架更重要 | |
| **标准库优先:** | |
| - Go 标准库提供了强大的网络编程能力 | |
| - 大多数场景下无需第三方框架 | |
| - 理解标准接口设计模式 | |
| ## 下一步学习 | |
| 在掌握了这些基础概念后,下一步将学习: | |
| - Go 标准库的 `net/http` 包 | |
| - JSON 处理和 API 设计 | |
| - 中间件和路由实现 | |
| - 数据库集成 | |
| - 生产环境部署 | |
| ## 总结 | |
| 后端开发的本质是理解计算机网络通信。通过掌握 TCP/IP、DNS、HTTP 等基础协议,你将能够: | |
| - 构建高性能的网络服务 | |
| - 解决复杂的网络问题 | |
| - 做出更好的技术决策 | |
| - 避免过度工程化 | |
| 记住:**框架会过时,但基础原理永恒。** 投资时间学习这些基础知识,将为你的职业生涯带来长久的价值。 | |
| --- | |
| _本指南基于 Efron Licht 的后端开发系列教程整理,适合希望深入理解后端开发原理的开发者。建议结合实际编程练习,循序渐进地掌握各项技能。_ | |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
从零开始的后端开发:完整入门指南
基于 Efron Licht 的后端开发系列教程
引言:为什么不需要框架?
当新手 Go 开发者问"我应该使用哪个 Web 框架?"时,答案总是:"你不需要框架。"
过度依赖框架的问题:
一个简单的事实: 向 Google 发送 HTTP 请求只需要五个单词:
系列概览
第一部分:基础概念(TCP、DNS、HTTP)
第二部分:实用后端(
net/http和encoding/json)net/http和net/url处理 HTTP 通信encoding/json管理 API 负载context管理超时和取消第三部分:完整功能(中间件、路由、数据库)
database/sql)第一部分:理解互联网的本质
什么是后端?
后端就是通过互联网连接计算机。
什么是互联网?
互联网是一个计算机网络,即使中间的一些计算机出现故障,也能可靠地相互通信。
互联网解决的两个核心问题:
解决方案:TCP/IP 协议
TCP:确保数据可靠传输
工作原理:
IP:确保消息到达正确目的地
工作原理:
地址和端口系统
IP 地址格式
端口概念
实践:构建 TCP/IP 服务器和客户端
示例:回显服务器
功能描述:
核心代码结构:
DNS:域名解析系统
DNS 的作用
将域名(如
google.com)转换为 IP 地址(如142.250.217.142)实现域名查找
HTTP 协议详解
HTTP 请求格式
示例:
HTTP 响应格式
示例:
状态码分类
常见 Header 说明
Hosteblog.fly.devContent-Typeapplication/jsonContent-Length47User-Agentcurl/7.64.1Accepttext/htmlURL 编码
需要编码的字符:
%20%25查询参数:
构建 HTTP 库
核心数据结构
实现要点
content-type→Content-Typeio.WriterTo、fmt.StringerNewRequest()和WithHeader()方法\r\n行分隔符学习建议
1. 渐进式学习
2. 深入理解原理
3. 实践项目推荐
4. 调试技巧
核心理念
简单性胜过复杂性:
标准库优先:
下一步学习
在掌握了这些基础概念后,下一步将学习:
net/http包总结
后端开发的本质是理解计算机网络通信。通过掌握 TCP/IP、DNS、HTTP 等基础协议,你将能够:
记住:框架会过时,但基础原理永恒。 投资时间学习这些基础知识,将为你的职业生涯带来长久的价值。
本指南基于 Efron Licht 的后端开发系列教程整理,适合希望深入理解后端开发原理的开发者。建议结合实际编程练习,循序渐进地掌握各项技能。