这一系列的文章是为了帮助没有进行过严肃 Web 开发的朋友入门 Web 后端,从语言到框架再到规范(industry standard 和 code standard),目的是提供一条流畅的路径。
第一天:学习语言
选择 Go 是因为它简单易学,有自带的 HTTP 开发套件,而且有非常丰富的社区支持。
要我一言以蔽之,Go 的设计和使用哲学大概能被总结为:
- 没有抽象(abstraction)
- 手写,要么就代码生成(code generation)
- 并发(noun),优雅地并发(v)
这些特性将在后面的项目案例中体现,在此之前,先来学习 Go 的基础吧。
安装
作者本人不推荐任何手动放置 binary 或者 msi/setup.exe 等任何安装方式,推荐使用包管理器。使用包管理器有利于与笔者的环境同步,减少笔者帮你 debug 的成本,并且 Go 生态的很多工具推荐使用依赖于系统的包管理器安装。
brew install go # macOS,请去 https://brew.sh/ 查看安装方法
scoop install go # Windows,请去 https://scoop.sh/ 查看安装方法
对于编写 Go 代码的相关工具链,推荐使用 VSCode 的官方 Go 插件,安装后会自动提示安装相关的工具,需要在安装的时候考虑中国大陆的网络环境。
学习
可以查看 Go language tour 来学习 Go 的基础语法。其中 Generic(范型)
章节可以被跳过,因为 Go 的范型在 1.18 版本才被引入,生态几乎已经习惯了没有范型的日子,写业务的时候用到的概率不大。
过完语法之后,可以酌情过一遍 Go by examples,并加入书签,这几天写项目实践的时候可能需要频繁查阅。
练习
这个系列通过编写项目来辅助理解。为了学习的效率,非常推荐使用 AI 开发工具的 tab completion 来辅助编码,但要保证之后 review 生成的代码能完全理解,本文中的所有练习题中的任何步骤都保证可以用 AI 编码工具 5 次 prompt 内完成,如果对中间的步骤有疑问,可以(最好)直接使用 AI 工具来询问。
第一个项目:静态文件服务器
这个项目的目标是编写一个简单的静态文件服务器,能够处理 GET 请求并返回指定目录下的文件。比如对于如下的目录结构:
public/
index.html
main.go
在 main.go 中编写代码,使得:
- 当访问
http://localhost:8080/index.html
时,能够返回public/index.html
的内容,同理对于其他文件。 - 当访问不存在的文件时,返回 404 错误。
提示:
- 使用 Go 原生的
net/http
包来处理 HTTP 请求。 - 使用
path.filepath
来处理文件路径。 - 使用
os.open
来创建文件句柄,该文件句柄实现了io.Reader
接口,可以直接编写io.Copy(w, file)
返回给 HTTP 响应。
第二个项目:Todo API
这个项目的目标是编写一个简单的 Todo API,能够处理基本的 CRUD 操作,借此接近 Go 在实际项目中的使用。这个项目的主要目的是让你熟悉并习惯代码生成,来减少项目开发中重复的 boilerplate 代码。
具体的任务如下:
- 使用 openapi-generator 生成结构完整,但是未实现的后端代码。
- 使用 ent ORM 生成数据库模型和相关的 CRUD 操作代码。
- 组合这两部分代码,完成一个简单的 Todo API。
OpenAPI 是一种使用 YAML 或 JSON 描述 API 的规范。它通常用于后端开发中,由后端编写 OpenAPI 文档,来形式化地描述 API 的接口、请求和响应格式等,来让前端和后端开发人员能够更好地协作。
这是这个项目所需的 OpenAPI 定义,可以直接复制到一个 todo.yaml
文件中。
然后你可以使用以下命令生成代码:
pnpx @openapitools/openapi-generator-cli generate -i todo.yaml -g go-gin-server -o ./todo-api -p packageName=todoapi --git-user-id example --git-repo-id go-todo-api
打开 ./todo-api
,这是一个完整的项目,你之后的工作可以完全在这个文件夹下面完成。
运行 go mod tidy
来安装依赖。查看 ./go/api_default.go
,这个文件是唯一需要你完成的,剩下的代码都是被生成的模板代码,如果想对项目有深入理解,可以酌情阅读。
然后使用 ent
生成数据库模型和相关的 CRUD 操作代码。详细的说明可以参考 ent 的文档。对于新手来说推荐使用 sqlite 数据库来避免复杂的配置。
最终,在 api_default.go
中填写 ent 的 CRUD 操作代码,完成 Todo API 的实现。
第三个项目:爬虫
来编写一个爬虫项目来练习 Go 的并发编程。这个项目的目标是爬取一个网站的所有文章。
具体的任务如下:
- 查看 JSON Placeholder,了解它的 API 结构。
- 编写代码访问
https://jsonplaceholder.typicode.com/posts
,获取所有文章的列表。 - 根据文章列表中的文章 id,访问
https://jsonplaceholder.typicode.com/posts/{id}
,获取每篇文章的详细内容。
提示:
- 使用
net/http
包来发送 HTTP 请求。 - 使用
sync.WaitGroup
来管理并发请求。 - 使用
encoding/json
包来解析 JSON 数据,这部分推荐交给 AI 解释,记得附带上下文这是 jsonplaceholder 的 post 相关。
第二天:后端框架
昨天介绍的 codegen 出一堆后端代码仅限于简单开发,日常的开发工作中最好还是自己认真设计才好,说到设计,技术选型就逃不开,框架就是一个绕不开的话题。
虽然 Go 有足够方便的标准库,但是使用一些轻量级的框架可以让开发更高效。出于作者的个人品味(JS 出身),我推荐新手学习 fiber。喜欢它的理由很很多,主要是:
- 轻量,少包装,至少看起来还是原汁原味的 Go。
- 受 Express.js 的影响很大,API 设计和使用习惯都很像,非常好上手。
- 文档非常友好,示例代码也很丰富清晰。
- 功能全、社区活跃、维护良好,是个健康的开源项目。
创建项目
遵从 Go 的惯例,创建一个项目,然后手动添加 fiber 依赖。
mkdir go-fiber-todo
cd go-fiber-todo
go mod init https://github.com/username/go-fiber-todo
go get github.com/gofiber/fiber/v2
Hello World
package main
import (
"github.com/gofiber/fiber/v2"
)
func main() {
app := fiber.New()
// 定义一个 GET 路由
app.Get("/", func(c *fiber.Ctx) error {
return c.SendString("Hello, World!")
})
// 启动服务器
app.Listen(":3000")
}
这就是一个简单的 Fiber 应用,监听在 3000 端口,并在根路径返回 “Hello, World!”,你可以使用 go run main.go
来运行这个应用。
路由
路由是将 HTTP 请求映射到处理函数的机制。Fiber 提供了非常直观的路由定义方式。
HTTP 方法
可以使用 app.Get
、app.Post
等方法来定义不同的 HTTP 方法的路由。
app.Get("/users", func(c *fiber.Ctx) error {
// 处理 GET /users 请求
})
app.Post("/users", func(c *fiber.Ctx) error {
// 处理 POST /users 请求
})
路由方法,或者说 HTTP 方法,是一种约定,表示请求的意图。常见的 HTTP 方法有:
GET
:获取资源POST
:创建资源PUT
:更新资源DELETE
:删除资源PATCH
:部分更新资源HEAD
:获取资源的元信息OPTIONS
:获取资源的可用方法
在良好设计的后端 API 中,应当使用合适的 HTTP 方法来表示请求的意图,这样可以提高 API 的可读性和可维护性。
路由参数
路由参数可以通过在路径中使用冒号 :
来定义。
app.Get("/users/:id", func(c *fiber.Ctx) error {
id := c.Params("id") // 获取路由参数 id
return c.SendString("User ID: " + id)
})
如此一来,当访问 /users/123
时,id
的值将是 123
,可以在处理函数中使用 c.Params("id")
来获取这个值,在这个例子中,返回的字符串将是 “User ID: 123”。
路由组
可以观察到,现实世界的大部分应用的后端路由都是非常复杂的,分为多段,通常被按照功能模块进行分组。比如对于一个博客平台,可能有如下的路由结构:
/api
/users
/{id}
/posts
/{id}
/comments
/{id}
这是使用路由组来组织路由的绝佳场景,在 Fiber 中,可以使用 app.Group
来创建路由组。
package main
import (
"log"
"github.com/gofiber/fiber/v2"
)
func handler(c *fiber.Ctx) error {
id := c.Params("id")
path := c.Path()
return c.SendString("匹配路径: " + path + " | 获取 ID: " + id)
}
func main() {
app := fiber.New()
api := app.Group("/api")
users := api.Group("/users")
users.Get("/:id", handler) // 处理 GET /api/users/:id 请求
posts := api.Group("/posts")
posts.Get("/:id", handler) // 处理 GET /api/posts/:id 请求
comments := api.Group("/comments")
comments.Get("/:id", handler) // 处理 GET /api/comments/:id 请求
log.Fatal(app.Listen(":3000"))
}
获取请求信息
在处理函数中,可以通过 c
参数获取请求的各种信息。
对于一个标准的 HTTP 报文有以下几个部分:
- 请求行(Request Line):包含 HTTP 方法、请求路径和 HTTP 版本。
- 请求头(Request Headers):包含请求的元信息,比如
Content-Type
、User-Agent
等。通常用于描述请求的格式、客户端信息等,方便后端更好的解析 body 或者进行其他处理。常见的用法是设置请求的内容类型(Content-Type)和接受的响应类型(Accept),以及其他一些元信息,比如认证信息(Authorization)、缓存控制(Cache-Control)等,这些都可以通过请求头来协商,具体的标准可以参考 HTTP 请求头列表。 - 请求体(Request Body):包含请求的实际内容,比如 POST 请求提交的数据。
在 Fiber 中,可以通过 c
参数来获取这些信息,具体的信息可以参考 Fiber 的文档。
发送响应
Body
在处理函数中,可以使用 c.SendString
、c.JSON
等方法来发送 body。
app.Get("/hello", func(c *fiber.Ctx) error {
return c.SendString("Hello, Fiber!")
})
app.Get("/json", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"message": "Hello, JSON!",
"status": "success",
})
})
与此之外,Fiber 还支持发送文件、重定向等操作,可以参考 Fiber 的文档。
状态码
对于响应的状态码,Fiber 会自动根据处理函数的返回值来设置状态码,如果需要手动设置,可以使用 c.Status
方法。
app.Get("/notfound", func(c *fiber.Ctx) error {
return c.Status(fiber.StatusNotFound).SendString("Not Found")
})
对于状态码的定义,可以参考 HTTP 状态码列表,常见的状态码有:
200 OK
:请求成功201 Created
:资源创建成功204 No Content
:请求成功,但没有内容返回400 Bad Request
:请求无效401 Unauthorized
:未授权403 Forbidden
:禁止访问404 Not Found
:资源未找到500 Internal Server Error
:服务器内部错误
由于状态码的设计过于古早,有些状态码的语义并不明确,在业务开发中,往往只需要使用这些常见的状态码,并与前端约定一套自定义的、代表着业务含义的状态码,放在 JSON 的 body 里,比如:
{
"code": 1001,
"message": "用户未登录"
}
响应头
在发送响应时,可以使用 c.Set
方法来设置响应头。
app.Get("/set-header", func(c *fiber.Ctx) error {
c.Set("Content-Type", "application/json")
return c.JSON(fiber.Map{
"message": "Header Set",
})
})
响应头通常用于描述响应的元信息,比如内容类型(Content-Type)、缓存控制(Cache-Control)、跨域资源共享(CORS)等。
中间件
中间件是一种框架提供的抽象,用于在请求和响应之间插入额外的处理逻辑。用户可以将业务中的一些通用逻辑抽象成中间件,比如日志记录、认证、错误处理等。
比如对于经典业务场景,检查用户是否登录,可以使用中间件来实现:
func isUserLoggedIn(c *fiber.Ctx) bool {
// 假设我们通过某种方式检查用户是否登录
// 这里仅为示例,实际应用中需要根据具体的认证逻辑来实现
return c.Get("Authorization") != ""
}
app.Use(func(c *fiber.Ctx) error {
// 检查用户是否登录
if !isUserLoggedIn(c) {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "User not logged in",
})
}
return c.Next() // 调用下一个中间件或处理函数
})
中间件可以通过 app.Use
方法来注册,Fiber 也提供了一些内置的中间件,比如日志记录、CORS 等,可以直接使用。
这些就是常见的框架特性和使用方法,接下来可以将昨天的 Todo API 项目用 Fiber 重写一遍,来熟悉框架的使用。在第三天,我们将详细介绍后端开发中的常用设计模式和一般业务的开发流程(认证、Restful、测试等)。
第三天:常见开发流程
有一些常见的流程,只需要掌握一些精髓就可以胜任,学习的性价比很高。
Authentication(认证)
认证解决的是用户身份是谁的问题。
JWT
在现代后端开发中,通常使用 JWT(JSON Web Token)来实现认证。JWT 是一种基于 JSON 的开放标准(RFC 7519),用于在网络应用环境间安全地传递声明。
JWT 的结构由三部分组成:头部(Header)、载荷(Payload)和签名(Signature)。头部通常包含令牌的类型和所使用的签名算法,载荷包含声明信息,签名用于验证令牌的完整性。载荷中可以包含用户信息、权限等数据,是完全可以自定义的部分。而签名部分则是使用密钥对头部和载荷进行加密,确保令牌的安全性。
一个典型的 JWT 结构如下所示(三个部分之间用点(.
)分隔):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30
对于 JWT 的详细规范,可以参考 JWT 官网的介绍。
JWT in practice
在实际应用中,通常会在用户登录时生成一个 JWT,并将其返回给客户端。客户端在后续的请求中将这个 JWT 放在请求头中,以证明自己的身份。
在 Go 中,可以使用 github.com/golang-jwt/jwt
包来处理 JWT 的生成和验证,具体的使用方法可以参考 [Go JWT 文档中的 example](https://github.com/golang-jwt/jwt#examples)
Bearer Token
在大部分业务系统中,JWT 通常会被放在 HTTP 请求的 Authorization
头中,格式为 Bearer <token>
,这是一种常见的约定,用于表示请求中携带的令牌。
more complexity…
可能你已经认识到,JWT 只是一种灵活的加密方式,它只保证信息的完整性和真实性,并不涉及用户的登录状态等等。经过 JWT 认证的用户只能证明自己是某个用户,并不能证明自己是登录状态的用户。
在实际应用中,通常会结合数据库来存储用户的登录状态,比如使用 Redis/RDBMS 来存储用户的登录状态(被称为 session),并在 JWT 中携带 session ID。这样可以在验证 JWT 时,查询数据库来确认用户的登录状态。引入 session 后便可以对用户的登录状态进行更细粒度的控制,比如设置限制多个设备登录、强制下线等。
诸如此类的考虑会让 JWT 的使用变得更加复杂,但这也是后端开发中常见的场景,这部分内容可以参考 The Copenhagen Book 来理解。
Restful API
Restful API 是一种基于 HTTP 协议的 API 设计风格,强调资源的概念和状态转移。它使用 HTTP 方法(GET、POST、PUT、DELETE 等)来操作资源,并通过 URL 来标识资源。与第二天模糊提到的细节不同,Restful API 的设计有一些更严格的规范和约定。学习这部分约定有助于规范 API 的设计,提高 API 的可读性和可维护性。
可以参考 阮一峰的博客 来了解其设计原则。
测试驱动开发(TDD)
测试驱动开发(TDD)是一种软件开发方法论,强调在编写代码之前先编写测试用例。TDD 的核心思想是通过测试来驱动代码的设计和实现,从而提高代码的质量和可维护性。
TDD 的流程通常包括以下几个步骤:
- 编写一个失败的测试用例(Red):首先编写一个测试用例,描述预期的功能或行为,但此时代码尚未实现,因此测试会失败。
- 编写代码使测试通过(Green):编写最少量的代码,使得测试用例通过。
- 重构代码(Refactor):在测试通过后,对代码进行重构,优化代码结构和性能,同时确保测试用例仍然通过。
- 重复以上步骤:继续编写新的测试用例,重复上述过程。
在 Go 中实行 TDD 非常方便,Go 的测试框架内置在标准库中,可以使用 go test
命令来运行测试用例。测试文件通常以 _test.go
结尾,测试函数以 Test
开头。
值得一提的是,AI 非常擅长生成测试用例,尤其是对于简单的函数和方法。可以使用 AI 工具来生成测试用例,并在编写代码时进行验证。
结构化日志
结构化日志是一种将日志信息以结构化的方式存储和输出的日志格式。相比于传统的文本日志,结构化日志可以更方便地进行查询、分析和处理。结构化日志通常使用 JSON 格式来存储日志信息,每条日志记录包含多个字段,每个字段都有明确的含义。这样可以方便地对日志进行过滤、排序和聚合等操作。
在 Go 中,可以使用 zap
等日志库来实现结构化日志。这里有一篇来自 betterstack 的教程。
容器化
容器化是现代后端开发中非常重要的一部分,是云原生、微服务、DevOps 等概念的基础。容器化可以将应用及其依赖打包到一个轻量级的容器中,从而实现应用的快速部署、扩展和管理。
对于 Fiber 应用来说,可以直接参考其官方案例。
写入项目文件夹下 Dockerfile
之后,可以使用以下命令构建镜像:
docker build -t go-fiber-todo .
然后使用以下命令运行容器:
docker run -p 3000:3000 go-fiber-todo
虽然并不要求在开发时就使用容器,但是在开发时时常在容器内运行应用是一个好习惯,可以避免 breaking change 太多导致上线的时候出问题。