从零设计 Go HTTP 请求封装库
本文主要讲述 http 请求客户端的实现与设计思路,以及一些部分核心代码!
设计目标
- 特点:轻量、极简、易用、零依赖、新特性(基于
Go 1.21
面向未来) - 目标:设计一个功能丰富且易于使用的
Go
版本的HTTP
客户端工具。
背景原因
尽管 Go
语言的 Native
HTTP
客户端(net/http
)功能强大,但在某些情况下使用起来相对繁琐,尤其是错误处理。
此外,一些现有的开源 HTTP
客户端库可能包含过多的冗余功能或缺乏特定需求的情况。此外,一些库可能受到历史包袱的影响。
鉴于上述原因,决定亲手打造一个专注于解决这些问题的自定义 HTTP
客户端工具。
获取灵感
在设计过程中,参考了多个 Go
语言开源解决方案,包括 resty
、grequests
和 larksuite
的 larkcore
,这些库提供了丰富的功能和实现方式,为得到有价值的参考。
此外,还研究了其他编程语言中著名的 HTTP
请求库,例如 Python
的 Requests
、NodeJs
的 Axios
和 Java
的 OkHttp
,这些库在各自的生态系统中广泛应用,提供了一些设计灵感和最佳实践。
综合借鉴了这些资源,旨在创建一个强大且易于使用的 Go HTTP
客户端,以满足各种需求,并为 Go
社区贡献一个有价值的工具。
设计哲学
OkHttp
、Requests
和 Axios
是三种流行的 HTTP
客户端库,它们在不同的编程语言中提供了 HTTP
请求的功能,每个库都有其自己的 API
风格和设计哲学。
-
API
风格:OkHttp
提供了简单且直观的链式调用API
,使得构建和执行HTTP
请求变得容易。- 它的
API
风格强调了清晰的方法链,可以在一行代码中构建复杂的请求。
-
设计哲学:
OkHttp
的设计哲学是提供高性能、灵活性和可扩展性,同时保持简洁性和易用性。
-
API
风格:Requests
提供了简单、一致的API
,易于使用。- 它的
API
风格强调了清晰的函数调用,可以轻松执行各种HTTP
请求。
-
设计哲学:
Requests
的设计哲学是让HTTP
请求变得尽可能容易和直观,同时提供丰富的功能。
-
API
风格:Axios
提供了基于Promise
的API
,使用异步函数来执行HTTP
请求。- 它的
API
风格强调了Promise
和async/await
的使用,使得异步代码更容易管理。
-
设计哲学:
Axios
的设计哲学是提供现代异步处理方式,与Promise
和async/await
集成,同时保持了灵活性和可配置性。
指导原则
在这个项目中,性能并不是我们的主要关注点。尽管性能在某些应用场景中非常重要,但在当前情况下,我们并不追求微小的性能差异,因为它不是我们的首要目标。相反,我们更加注重易用性、API
设计和用户体验。
我们坚信,在绝大多数情况下,为了追求极致性能而放弃用户友好性是不明智的。我们更倾向于提供卓越的用户体验,而不是不必要地追求性能的细微提升。
关于零内存分配,目前并不是我们关注的核心问题。因此,我们不会过于专注于基准测试的结果,而是将快速实现功能作为我们的首要目标。当然,我们仍然会尽量避免过多的数据拷贝和滥用对象,以减少 GC
垃圾回收的额外开销。
另外,如果你担心性能问题,可以使用 Go
的性能分析工具来识别潜在的性能瓶颈,然后根据需要进行优化。多说一句,如果想在 Go
语言中进行深度内存优化,了解 runtime
包是非常重要的。runtime
包提供了对 Go
语言运行时系统的访问,可以用于执行与内存和并发相关的各种操作。
总之,我们不会过度追求优化,因为这不符合我们最初的 “大道至简” 的设计思路。通过这一指导原则,我们将确保项目在提供丰富功能的同时,也提供友好的 API
和卓越的用户体验。
实现功能
- 日志记录
- 异常处理
- 易用的
API
设计 - 支持
Rest
解析 - 文件处理
- 个性化功能
需求分析
我们的项目着重保持轻量,不会过度设计。以下是各个功能模块的详细说明:
日志模块:✅
- 选择:我们采用标准库中的
slog
日志库作为我们的记录器。 - 理由:这个库性能足够(仅次于
zap
),并且无需引入第三方库,这对我们来说非常重要。
异常处理:✅
- 包括:记录异常发生的时间、位置、堆栈信息、原始错误、描述信息等。
- 采用
Rust
语言的unwrap
错误处理风格。
前后分离:✅
-
前台提供多套易用的
API
,并支持使用Restful
进行包装:- 首先是我们自己独特的
ignite-style
风格。 - 另外,我们也参考了
OkHttp
、Requests
和Axios
的API
风格,成年人的世界不做选择,我们全都要!
- 首先是我们自己独特的
-
后台部分根据功能的不同,我们将其分为六大结构体:
- 包括客户端、请求、响应、错误、配置、扩展。
- 我们提供了多种初始化和自定义的方式,以及多种
Getter
/Setter
/Option
方法。
这样的设计使得后台能够与前台 API
协同工作,完成请求的发送、响应和解析。
文件处理:✅
- 文件处理模块将包括上传、下载功能,以及进度显示和断点续传功能。
个性化功能(高级):
Middleware
请求/响应中间件Hook
当Success/Failed/Panic
时触发的回调函数JSON
解析器注册(灵感来源 github.com/go-resty/re…)- 允许
Get
请求携带请求体(灵感来源 github.com/go-resty/re…) - 支持
XML
格式 - 支持传递
Context
上下文 - 请求的重试机制
- 并发请求
- 伪装随机
User-Agent
头 SSE
客户端
这些模块和功能将一起构建一个丰富且易于使用的 HTTP
客户端工具,旨在满足各种需求。我们将始终保持轻量和易用性,以提供出色的用户体验。
需求总结
- 目标:实现一个极简、易用、功能丰富的
HTTP
客户端 - 实现:基于
Go
标准库net/http
的二次封装 - 版本:
Go 1.21+
- 依赖:不依赖任何第三方库,仅使用官方子模块
go 1.21
require (
golang.org/x/net v0.15.0
golang.org/x/text v0.13.0
)
读书破万卷,下笔如有神
🐔 先干一碗鸡汤。
我们不急于开始编写代码,而是优先专注于项目的设计、结构体的设计等。换句话说,首要任务是确保我们拥有清晰的思路。那么,如果我们缺乏经验或者陷入了思维僵局,应该怎么办呢?
当我们追求更高水平,不满足于仅仅是 CRUD
的时候,我们可能会思考如何编写基础库或通用组件,以提高自己的核心竞争力。在这个过程中,特别是当我们还是新手的时候,学会“抄袭”和“模仿”是进阶的必修课之一。接下来,我们可以进行二次开发或者尝试对源码进行魔改等。然而,编写代码并不是一门纯粹的理论学科,它需要大量的实践。
经常听说的 “10w 行代码经验” 并不是指简单地重复 10w 次 "Hello World",而是需要不断地进行思考和练习的过程。在这个过程中,我们会积累大量的实际经验,逐渐提高自己的编程水平。
举个例子,如果我们想要实现某个功能,可以参考其他知名库是如何实现的,看看优秀的前辈和大牛是如何思考问题的。仔细观察作者是如何组织项目结构的。这样做有助于我们更好地理解和规划自己的项目。
请记住,这并不是鼓励你失去个性或者创新性,而是借鉴他人的精华部分。需要明白,没有人的设计是完美无缺的,我们的目标是在当前阶段做到最好,然后在后续的迭代中不断改进和成长。
参考分析 Github HTTP 库
地址 | 星数 | 最近修改 | 首次 | 描述 |
---|---|---|---|---|
pkg.go.dev/net/http | 基础库 | |||
github.com/parnurzeal/… | 3.3k | 3 年前 | 2014年 | 基于 net/http 封装 |
github.com/levigross/g… | 2k | 1 年前 | 2015年 | 基于 net/http 封装 |
github.com/go-resty/re… | 8.5k | 持续活跃 | 2015年 | 基于 net/http 封装 |
net/http 标准库
标准库的设计:
http.Request
和 http.Response
是互通的,其主要原因是由于 HTTP
请求和响应是相互关联的,因此设计上允许它们之间的信息交换和互通,这样使得开发人员能够方便地处理 HTTP
请求和响应的各个方面。
Resty 库
首先,让我们来分析 resty 库( github.com/go-resty/re… )该库拥有 8.5k 星,且是目前唯一一款还时常活跃的三方库
# 删除了一些无关紧要的文件后,目录大致如下:
.
├── LICENSE
├── README.md
├── client.go # 客户端相关
├── example_test.go
├── go.mod
├── go.sum
├── middleware.go
├── redirect.go
├── request.go # 请求相关
├── response.go # 响应相关
├── resty.go
├── retry.go
├── trace.go
├── transport.go
└── util.go
Grequests 库
我们参考下另外一个库( github.com/levigross/g… ),该库拥有 2k 星,尽管已经不常维护了,不过不妨碍
# 删除了一些无关紧要的文件后,目录大致如下:
.
├── LICENSE
├── README.md
├── base.go
├── file_upload.go
├── go.mod
├── go.sum
├── request.go
├── response.go
├── session.go
└── utils.go
larksuite 库
参考 github.com/larksuite/o…
如何设计你的结构体
在 Go 中,如何设计大的结构体(包括嵌套结构体)通常涉及权衡性能、可读性和维护性。以下是一些关于如何设计大的结构体的一些建议。
嵌套结构体 vs. 扁平结构体
- 扁平结构体: 将所有字段平铺在一个大的结构体中。这样的结构体可能会更容易访问,因为你可以通过简单的点号操作符访问字段,而不需要多层嵌套。但这样的可读性会降低,因为所有字段都在一个地方,可能会显得混乱。
- 嵌套结构体: 使用嵌套结构体将相关字段组织在一起。这可以提高代码的可读性和维护性,因为相关字段在逻辑上分组在一起,而不是散布在整个结构体中。它们可以将相关的字段组织在一起,使代码更具结构。每个子结构体可以有自己的设计模式。组合和模块化是软件工程中的重要原则,可以帮助你将复杂的问题分解为更小的可管理部分。这对于大型项目或团队合作尤为重要。
性能说明
理论上来说,嵌套结构体和扁平结构体的性能差异通常可以忽略不计。在大多数情况下,Go 的编译器和运行时系统会对这两种结构体进行高效优化,因此性能差异很小。无论是嵌套结构体还是扁平结构体,它们都可以在栈上分配内存,而不一定会导致堆分配。
性能的关键通常更多地取决于代码的使用方式和算法复杂度,而不是结构体的嵌套层次。因此,你可以根据代码的可读性和维护性来选择使用嵌套结构体或扁平结构体,而不必太担心性能问题。
但是,有一些情况下可能会影响性能:
- 内存对齐: 嵌套结构体可能会导致内存布局不够紧凑,从而浪费一些内存。但这种浪费通常是微不足道的。
- 访问字段的方式: 在嵌套结构体中,访问字段需要多次点操作,而在扁平结构体中,只需要一个点操作。如果在代码中频繁访问嵌套结构体的字段,可能会稍微影响性能,但通常也是微小的。
总之,结构体的设计应该根据具体的应用需求和项目的规模来决定。在大多数情况下,良好的代码组织和清晰的结构会比微小的性能优势更为重要。只有在极端情况下,才需要考虑微小的性能差异。优化代码的性能通常需要根据具体情况进行,而不是基于结构体的嵌套层次。
开始设计我们的项目
参考了上述一些设计思路和理念,也许我们已经在脑海中勾画出了一些初步的构想。现在,让我们开始着手打造自己的项目吧。
好像我们还没有给这个项目取个名字,那就决定叫它 "ignite" 吧!这个名字充满了点燃、启发和激发创造力的寓意。那么,让我们开始吧!
项目结构
# 初期目录大致如下:
.
├── README.md
├── client.go # 客户端相关
├── config.go # 自定义配置
├── constants.go # 值对象,类型, 常量等
├── errors.go # 自定义错误
├── extensions.go # 扩展功能
├── go.mod
├── go.sum
├── request.go # 请求相关
└── response.go # 响应相关
系统架构
尽管这是一个纯后端项目,但也可以采用 “前后端分离” 的设计思想。思想并不会拘限于 WEB 的前后端交互。
结构体设计
HttpClient
结构体组合
// client.go
type HttpClient struct {
Client *http.Client // 客户端对象
R *Request // 请求对象
Resp *Response // 响应对象
ext *Extension // 扩展功能
conf *Config // 自定义配置
e *Exception // 异常处理
}
func NewHttpClient() *HttpClient {
return &HttpClient{
// TODO: 待完善...
}
}
func (hc *HttpClient) Do(r *http.Request) (*http.Response, error) {
// 构建client客户端
client := http.DefaultClient
if hc.Client != nil {
client = hc.Client
}
// 发送请求并获取响应
resp, err := client.Do(r)
if err != nil {
return nil, err
}
return resp, err
}
func execute(hc *HttpClient) error {
// 构建请求
rawReq, err := buildRequest(hc.R)
if err != nil {
return err
}
// 记时开始
hc.R.startedAt = time.Now()
// 发送请求
rawResp, err := hc.Do(rawReq)
if err != nil {
return err
}
// 记时结束
hc.Resp.receivedAt = time.Now()
// 解析响应
resp, err := buildResponse(rawResp)
if err != nil {
return err
}
// 将响应存储到HttpClient中
hc.Resp = resp
return nil
}
Request
自定义请求结构体
// request.go
type Request struct {
RawRequest *http.Request // 存储原生请求对象
URL string
Method string
QueryParam url.Values // 保持其原生类型
FormData url.Values // 保持其原生类型
Header http.Header // 保持其原生类型
Cookies []*http.Cookie // 保持其原生类型
Auth *Authorization
Body any
ctx context.Context // 请求上下文
payload *bytes.Reader
startedAt time.Time // 记录请求开始时间
super *HttpClient // super指向了“基类”,方便操作到其它结构体的方法,实现互通
}
type Authorization struct { // 认证相关
AuthType string
Token string
}
func buildRequest(r *Request) (*http.Request, error) {
// TODO: 待实现...
}
Response
自定义响应结构体
// response.go
type Response struct {
RawResponse *http.Response // 存储原生响应对象
isSuccess bool // 是否为2xx系列
statusCode int // 原生http状态码
body []byte // alias
size int64 // alias
receivedAt time.Time // 记录响应结束时间
super *HttpClient // super指向了“基类”,方便操作到其它结构体的方法,实现互通
}
func buildResponse(rawResp *http.Response) (*Response, error) {
// TODO: 待实现...
}
Exception
自定义错误结构体
// errors.go
type Exception struct {
Message string // 用于存储错误消息或描述
Trace string // 用于存储堆栈跟踪
Timestamp int64 // 用于记录错误发生的时间戳
Source *Source // 用于指示错误来源
Cause error // 原始错误信息
}
type Source struct {
FileName string // 用于存储错误发生的文件名
FuncName string // 用于存储错误发生的函数或方法名
LineNum int // 用于存储错误发生的行号
}
func (e *Exception) Error() string {
// TODO: 待实现...
}
Extension
自定义扩展结构体
// extensions.go
type Extension struct {
// logger 日志记录器
// serializer JSON序列化器
// middlewares 请求响应中间件
// hooks 事件节点回调函数
// ...
}
使用演示
Rest 风格
服务端使用 Gin
框架快速构建:
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
router := gin.Default()
router.GET("/", func(c *gin.Context) {
// 请求参数
params := c.Request.URL.Query()
// 返回标准 RESTful 响应 {"code": 0, "msg": "success", "data": }
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "success",
"data": params, // 将请求参数包装 REST 结构后进行返回
})
})
router.Run(":8080")
}
我们的 requests-style
配合 rest api
简直太棒了!
package main
import (
"fmt"
"github.com/xxxxx/ignite"
)
type Params struct {
Age string `json:"age"`
Gender string `json:"gender"`
Name string `json:"name"`
}
func main() {
requests := ignite.RequestsClient(ignite.New())
data := ignite.Restful[Params]().Parser(requests.Get("http://0.0.0.0:8080", ignite.H{
"name": "mystic",
"age": 26,
"gender": true,
})).Unwrap()
fmt.Printf("name: %s, age: %sn", data.Data.Name, data.Data.Age)
}
普通请求
通常我们可以使用 httpbin.org/# 网站作请求演示。
package main
import (
"fmt"
"github.com/xxxxx/ignite"
)
// 响应体
type HttpBinPostResp struct {
Args struct{} `json:"args"`
Data string `json:"data"`
Headers struct{} `json:"headers"`
Json Userinfo `json:"json"`
Origin string `json:"origin"`
Url string `json:"url"`
}
// 请求体
type Userinfo struct {
Name string `json:"name"`
Age int `json:"age"`
Gender bool `json:"gender"`
}
// 较复杂的POST请求
func main() {
r := ignite.Default()
r.R.
SetMethod(ignite.MethodPost).
SetURL("https://httpbin.org/post").
SetQueryParams(ignite.H{
"q": "foo",
"current": 1,
"page_size": 25,
"sorted": true,
}).
SetBody(&Userinfo{
Name: "mystic",
Age: 26,
Gender: true,
}).
SetHeaders(ignite.H{
"Accept": "*/*",
"Accept-Encoding": []string{"gzip", "deflate", "br"},
"Accept-Language": []string{"zh-CN,zh;q=0.9", "en-US,en;q=0.7"},
"Cache-Control": "no-cache",
})
var body HttpBinPostResp
r.JsonHandler(&body)
fmt.Printf("%+vn", body)
}
完整代码
如果你觉得这个项目还不错的话,请点赞、收藏或点个关注。计划在后续将代码开源到 GitHub
上。谢谢你的支持!