forked from Gitlink/gitlink-cli
561 lines
16 KiB
Markdown
561 lines
16 KiB
Markdown
# internal/client/client.go 阅读笔记(面向 Go 小白)
|
||
|
||
---
|
||
|
||
## 第 1 行:`package client`
|
||
|
||
**字面意思**:声明这个文件属于 `client` 包
|
||
|
||
**运行时作用**:这是项目的 HTTP 客户端模块,负责所有与 GitLink API 的通信。
|
||
|
||
---
|
||
|
||
## 第 3-16 行:import 导入依赖
|
||
|
||
```go
|
||
import (
|
||
"bytes" // 字节缓冲(用于构造请求体)
|
||
"encoding/json" // JSON 序列化/反序列化
|
||
"fmt" // 格式化输出
|
||
"io" // 输入输出接口
|
||
"net/http" // HTTP 协议
|
||
"net/url" // URL 处理
|
||
"strings" // 字符串操作
|
||
|
||
"github.com/gitlink-org/gitlink-cli/internal/auth" // 认证模块(带 Token 的 HTTP 客户端)
|
||
"github.com/gitlink-org/gitlink-cli/internal/config" // 配置管理
|
||
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors" // 错误定义
|
||
"github.com/gitlink-org/gitlink-cli/internal/output" // 输出格式化
|
||
)
|
||
```
|
||
|
||
**小白补充**:
|
||
|
||
| 包名 | 用途 | 在本文件中的作用 |
|
||
|------|------|-----------------|
|
||
| `bytes` | 字节操作 | 把 JSON 数据转成 HTTP 请求体 |
|
||
| `io` | 输入输出 | 读取 HTTP 响应体 |
|
||
| `net/http` | HTTP 协议 | 创建和发送 HTTP 请求 |
|
||
|
||
---
|
||
|
||
## 第 18-23 行:`Client` 结构体(核心!)
|
||
|
||
```go
|
||
type Client struct {
|
||
HTTP *http.Client
|
||
BaseURL string
|
||
Debug bool
|
||
SkipJSONSuffix bool
|
||
}
|
||
```
|
||
|
||
**字面意思**:定义 HTTP 客户端的结构
|
||
|
||
**运行时作用**:这是项目封装的 HTTP 客户端,所有 API 调用都通过它来完成。
|
||
|
||
**小白补充**:
|
||
|
||
### ① 每个字段的含义:
|
||
|
||
| 字段 | 类型 | 含义 |
|
||
|------|------|------|
|
||
| `HTTP` | `*http.Client` | Go 标准库的 HTTP 客户端(核心) |
|
||
| `BaseURL` | `string` | API 基础地址(如 `https://www.gitlink.org.cn/api`) |
|
||
| `Debug` | `bool` | 是否开启调试模式(打印请求/响应) |
|
||
| `SkipJSONSuffix` | `bool` | 是否跳过自动添加 `.json` 后缀(Wiki Gateway 需要) |
|
||
|
||
### ② `*http.Client` 是什么?
|
||
|
||
`http.Client` 是 Go 标准库提供的 HTTP 客户端,它包含:
|
||
- 连接池管理
|
||
- 超时设置
|
||
- Cookie 管理
|
||
- 传输层配置(如 TLS、代理)
|
||
|
||
我们项目在 `internal/auth/transport.go` 中对它进行了扩展,自动添加认证 Token。
|
||
|
||
---
|
||
|
||
## 第 25-35 行:`APIError` 结构体
|
||
|
||
```go
|
||
type APIError struct {
|
||
StatusCode int
|
||
Code interface{}
|
||
Message string
|
||
Kind clierrors.ErrorKind
|
||
Suggestion string
|
||
}
|
||
|
||
func (e *APIError) Error() string {
|
||
return fmt.Sprintf("[%v] %s", e.Code, e.Message)
|
||
}
|
||
```
|
||
|
||
**字面意思**:定义 API 错误的结构
|
||
|
||
**运行时作用**:封装 API 返回的错误信息,包含错误码、消息和解决建议。
|
||
|
||
**小白补充**:
|
||
|
||
### ① `Error()` 方法:
|
||
|
||
```go
|
||
func (e *APIError) Error() string {
|
||
return fmt.Sprintf("[%v] %s", e.Code, e.Message)
|
||
}
|
||
```
|
||
|
||
- 这是实现了 Go 的 `error` 接口
|
||
- 任何实现了 `Error() string` 方法的类型都可以作为 `error` 返回
|
||
- 这样 `APIError` 就可以像普通错误一样使用:`return apiErr`
|
||
|
||
### ② 为什么需要自定义错误类型?
|
||
|
||
普通的 `error` 只能包含一条消息,而我们需要:
|
||
- `StatusCode`:HTTP 状态码(404/403/500 等)
|
||
- `Code`:API 返回的业务错误码
|
||
- `Kind`:错误分类(认证错误/输入错误/服务器错误等)
|
||
- `Suggestion`:给用户的解决建议
|
||
|
||
---
|
||
|
||
## 第 37-46 行:`New` 函数(构造函数)
|
||
|
||
```go
|
||
func New() (*Client, error) {
|
||
// 1. 加载配置
|
||
cfg, err := config.Load()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 2. 创建并返回 Client
|
||
return &Client{
|
||
HTTP: auth.NewHTTPClient(), // 带认证的 HTTP 客户端
|
||
BaseURL: cfg.BaseURL, // 从配置获取 API 地址
|
||
}, nil
|
||
}
|
||
```
|
||
|
||
**字面意思**:创建一个新的 Client 实例
|
||
|
||
**运行时作用**:这是 Client 的构造函数,自动加载配置并创建带认证的 HTTP 客户端。
|
||
|
||
**小白补充**:
|
||
|
||
### ① `auth.NewHTTPClient()` 做了什么?
|
||
|
||
这个函数在 `internal/auth/transport.go` 中,它创建了一个 HTTP 客户端,并且:
|
||
- 自动从配置文件读取 Token
|
||
- 在每个请求的 `Authorization` 头中添加 `Bearer {token}`
|
||
- 处理 Token 过期等情况
|
||
|
||
### ② 配置文件的内容:
|
||
|
||
配置文件位于 `~/.config/gitlink-cli/config.yaml`,内容大致如下:
|
||
|
||
```yaml
|
||
base_url: https://www.gitlink.org.cn/api
|
||
gateway_base_url: https://gateway.gitlink.org.cn/api
|
||
token: your-token-here
|
||
```
|
||
|
||
---
|
||
|
||
## 第 48-168 行:`Do` 方法(核心!)
|
||
|
||
这是整个文件中**最重要的函数**,负责发送 HTTP 请求并解析响应。
|
||
|
||
### ① 路径处理(第 48-67 行)
|
||
|
||
```go
|
||
func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) {
|
||
// 自动添加 .json 后缀(GitLink API 约定)
|
||
if c.shouldAppendJSONSuffix(path) {
|
||
if idx := strings.Index(path, "?"); idx != -1 {
|
||
// 路径已经包含查询参数,在 ? 前面加 .json
|
||
basePath := path[:idx]
|
||
queryStr := path[idx:]
|
||
path = basePath + ".json" + queryStr
|
||
} else {
|
||
// 路径没有查询参数,直接加 .json
|
||
path += ".json"
|
||
}
|
||
}
|
||
|
||
// 构建完整 URL
|
||
fullURL := c.BaseURL + path
|
||
if query != nil && len(query) > 0 {
|
||
sep := "?"
|
||
if strings.Contains(fullURL, "?") {
|
||
sep = "&" // URL 已经有 ?,用 & 连接
|
||
}
|
||
fullURL += sep + query.Encode()
|
||
}
|
||
// ...
|
||
}
|
||
```
|
||
|
||
**字面意思**:处理请求路径,构建完整 URL
|
||
|
||
**运行时作用**:GitLink API 约定所有路径都需要 `.json` 后缀,这里自动添加。
|
||
|
||
**小白补充**:
|
||
|
||
- `c.BaseURL` 是 `https://www.gitlink.org.cn/api`
|
||
- `path` 是 `/users/me`
|
||
- 最终 `fullURL` 变成 `https://www.gitlink.org.cn/api/users/me.json`
|
||
|
||
### ② 请求体处理(第 69-77 行)
|
||
|
||
```go
|
||
// 处理请求体
|
||
var bodyReader io.Reader
|
||
if body != nil {
|
||
// 把 body 序列化成 JSON
|
||
data, err := json.Marshal(body)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// 转成 io.Reader(HTTP 请求需要的格式)
|
||
bodyReader = bytes.NewReader(data)
|
||
}
|
||
```
|
||
|
||
**字面意思**:把请求体转成 HTTP 可以发送的格式
|
||
|
||
**运行时作用**:如果有请求体(如 POST/PUT 请求),把 Go 的 map 转成 JSON 字符串,再转成字节流。
|
||
|
||
**小白补充**:
|
||
|
||
- `json.Marshal(body)`:把 Go 结构体/map 转成 JSON 字节数组
|
||
- `bytes.NewReader(data)`:把字节数组包装成 `io.Reader`(HTTP 请求体需要这个接口)
|
||
|
||
### ③ 创建 HTTP 请求(第 79-87 行)
|
||
|
||
```go
|
||
// 创建 HTTP 请求
|
||
req, err := http.NewRequest(method, fullURL, bodyReader)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 调试模式:打印请求信息
|
||
if c.Debug {
|
||
fmt.Printf("→ %s %s\n", method, fullURL)
|
||
}
|
||
```
|
||
|
||
**字面意思**:创建一个 HTTP 请求对象
|
||
|
||
**运行时作用**:`http.NewRequest` 创建请求对象,包含方法、URL 和请求体。
|
||
|
||
### ④ 发送请求(第 89-92 行)
|
||
|
||
```go
|
||
// 发送请求
|
||
resp, err := c.HTTP.Do(req)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("request failed: %w", err)
|
||
}
|
||
defer resp.Body.Close() // 确保响应体被关闭
|
||
```
|
||
|
||
**字面意思**:发送 HTTP 请求并获取响应
|
||
|
||
**运行时作用**:`c.HTTP.Do(req)` 发送请求,返回响应对象。
|
||
|
||
**小白补充**:
|
||
|
||
- `defer resp.Body.Close()`:**非常重要!** 确保响应体被关闭,避免资源泄漏
|
||
- `defer` 是 Go 的关键字,它会在函数返回前执行后面的语句
|
||
- 如果不关闭 `resp.Body`,HTTP 连接池会被占满,导致后续请求失败
|
||
|
||
### ⑤ 读取响应体(第 94-101 行)
|
||
|
||
```go
|
||
// 读取响应体
|
||
respData, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||
}
|
||
|
||
// 调试模式:打印响应信息
|
||
if c.Debug {
|
||
fmt.Printf("← %d %s\n", resp.StatusCode, string(respData[:min(len(respData), 200)]))
|
||
}
|
||
```
|
||
|
||
**字面意思**:把响应体读取成字节数组
|
||
|
||
**运行时作用**:`io.ReadAll(resp.Body)` 读取整个响应体内容。
|
||
|
||
**小白补充**:
|
||
- `resp.StatusCode` 是 HTTP 状态码(200=成功,404=未找到,500=服务器错误)
|
||
|
||
### ⑥ HTTP 状态码检查(第 103-113 行)
|
||
|
||
```go
|
||
// 检查 HTTP 状态码
|
||
if resp.StatusCode >= 400 {
|
||
info := lookupStatusInfo(resp.StatusCode)
|
||
return nil, &APIError{
|
||
StatusCode: resp.StatusCode,
|
||
Code: resp.StatusCode,
|
||
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))),
|
||
Kind: info.kind,
|
||
Suggestion: info.suggestion,
|
||
}
|
||
}
|
||
```
|
||
|
||
**字面意思**:如果状态码 >= 400,返回错误
|
||
|
||
**运行时作用**:HTTP 4xx/5xx 都是错误,这里封装成 `APIError` 返回。
|
||
|
||
**小白补充**:
|
||
- `lookupStatusInfo(resp.StatusCode)` 根据状态码查找对应的错误分类和建议
|
||
|
||
### ⑦ JSON 解析(第 115-120 行)
|
||
|
||
```go
|
||
// 解析 JSON 响应
|
||
var raw map[string]interface{}
|
||
if err := json.Unmarshal(respData, &raw); err != nil {
|
||
// 不是 JSON,直接返回原始内容
|
||
return output.SuccessEnvelope(string(respData), nil), nil
|
||
}
|
||
```
|
||
|
||
**字面意思**:把响应体解析成 Go 的 map
|
||
|
||
**运行时作用**:`json.Unmarshal` 把 JSON 字符串转成 Go 的 `map[string]interface{}`。
|
||
|
||
**小白补充**:
|
||
|
||
- `json.Unmarshal` 的第二个参数需要传递**指针**(`&raw`)
|
||
- `interface{}` 是 Go 的"万能类型",可以存储任何值
|
||
- 如果响应不是 JSON(比如返回的是 HTML 错误页面),就直接返回字符串
|
||
|
||
### ⑧ GitLink 业务错误检查(第 122-142 行)
|
||
|
||
```go
|
||
// 检查 GitLink 业务错误(响应体中的 status 字段)
|
||
if status, ok := raw["status"]; ok {
|
||
var statusCode float64
|
||
switch v := status.(type) {
|
||
case float64:
|
||
statusCode = v
|
||
case int:
|
||
statusCode = float64(v)
|
||
}
|
||
|
||
// status 不为 0、1、200 都是错误
|
||
if statusCode != 0 && statusCode != 200 && statusCode != 1 {
|
||
msg, _ := raw["message"].(string)
|
||
info := lookupStatusInfo(int(statusCode))
|
||
return output.ErrorEnvelope(int(statusCode), msg, info.suggestion), &APIError{
|
||
StatusCode: int(statusCode),
|
||
Code: int(statusCode),
|
||
Message: msg,
|
||
Kind: info.kind,
|
||
Suggestion: info.suggestion,
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**字面意思**:检查 GitLink API 返回的业务错误码
|
||
|
||
**运行时作用**:GitLink API 有时 HTTP 状态码是 200,但响应体中的 `status` 字段表示业务失败(如参数校验失败)。
|
||
|
||
**小白补充**:
|
||
|
||
GitLink API 的响应格式:
|
||
```json
|
||
{
|
||
"status": 0, // 0=失败, 1=成功, 200=成功
|
||
"message": "...", // 错误信息
|
||
"data": {...} // 数据
|
||
}
|
||
```
|
||
|
||
### ⑨ 自动解析 JSON 字符串数据(第 144-150 行)
|
||
|
||
```go
|
||
// 自动解析 JSON 字符串数据(GitLink API 的一个特性)
|
||
if dataStr, ok := raw["data"].(string); ok {
|
||
var parsedData interface{}
|
||
if err := json.Unmarshal([]byte(dataStr), &parsedData); err == nil {
|
||
raw["data"] = json.RawMessage(dataStr)
|
||
}
|
||
}
|
||
```
|
||
|
||
**字面意思**:处理 data 字段是 JSON 字符串的情况
|
||
|
||
**运行时作用**:GitLink 某些 API 返回的 `data` 字段是字符串形式的 JSON,需要再次解析。
|
||
|
||
**小白补充**:
|
||
|
||
比如响应是这样的:
|
||
```json
|
||
{
|
||
"status": 1,
|
||
"data": "{\"name\": \"test\"}" // data 是字符串!
|
||
}
|
||
```
|
||
|
||
这里需要把 `"{\"name\": \"test\"}"` 再解析成 `{"name": "test"}`。
|
||
|
||
### ⑩ 构建分页元数据(第 152-166 行)
|
||
|
||
```go
|
||
// 构建分页元数据
|
||
var meta *output.Meta
|
||
if tc, ok := raw["total_count"]; ok {
|
||
meta = &output.Meta{}
|
||
if v, ok := tc.(float64); ok {
|
||
meta.TotalCount = int(v)
|
||
}
|
||
if v, ok := raw["page"].(float64); ok {
|
||
meta.Page = int(v)
|
||
}
|
||
if v, ok := raw["limit"].(float64); ok {
|
||
meta.Limit = int(v)
|
||
}
|
||
}
|
||
|
||
// 返回成功的 Envelope
|
||
return output.SuccessEnvelope(raw, meta), nil
|
||
```
|
||
|
||
**字面意思**:从响应中提取分页信息
|
||
|
||
**运行时作用**:如果 API 返回了分页信息(total_count/page/limit),提取出来作为 `Meta`。
|
||
|
||
---
|
||
|
||
## 第 170-184 行:便捷方法
|
||
|
||
```go
|
||
func (c *Client) Get(path string, query url.Values) (*output.Envelope, error) {
|
||
return c.Do("GET", path, nil, query)
|
||
}
|
||
|
||
func (c *Client) Post(path string, body interface{}) (*output.Envelope, error) {
|
||
return c.Do("POST", path, body, nil)
|
||
}
|
||
|
||
func (c *Client) Put(path string, body interface{}) (*output.Envelope, error) {
|
||
return c.Do("PUT", path, body, nil)
|
||
}
|
||
|
||
func (c *Client) Delete(path string, query url.Values) (*output.Envelope, error) {
|
||
return c.Do("DELETE", path, nil, query)
|
||
}
|
||
```
|
||
|
||
**字面意思**:封装常见的 HTTP 方法
|
||
|
||
**运行时作用**:提供更简洁的调用方式,比如 `client.Get("/users/me", nil)` 而不是 `client.Do("GET", "/users/me", nil, nil)`。
|
||
|
||
---
|
||
|
||
## 第 186-225 行:错误信息映射
|
||
|
||
```go
|
||
type statusInfo struct {
|
||
kind clierrors.ErrorKind
|
||
message string
|
||
suggestion string
|
||
}
|
||
|
||
var statusMessages = map[int]statusInfo{
|
||
-2: {clierrors.KindAuth, "未登录或 Token 已过期",
|
||
"运行 gitlink-cli auth login 重新登录"},
|
||
-1: {clierrors.KindInput, "参数校验失败",
|
||
"检查必填参数是否缺失"},
|
||
401: {clierrors.KindAuth, "认证失败",
|
||
"运行 gitlink-cli auth login 登录"},
|
||
403: {clierrors.KindForbidden, "权限不足",
|
||
"请确认账号有此仓库的访问权限"},
|
||
404: {clierrors.KindNotFound, "资源不存在",
|
||
"检查 owner/repo/id 是否正确"},
|
||
// ... 更多状态码
|
||
}
|
||
|
||
func lookupStatusInfo(code int) statusInfo {
|
||
if info, ok := statusMessages[code]; ok {
|
||
return info
|
||
}
|
||
return statusInfo{
|
||
kind: clierrors.KindUnknown,
|
||
message: fmt.Sprintf("API 返回错误码 %d", code),
|
||
}
|
||
}
|
||
```
|
||
|
||
**字面意思**:根据错误码查找对应的错误信息
|
||
|
||
**运行时作用**:把枯燥的错误码转换成人类可读的错误信息和解决建议。
|
||
|
||
---
|
||
|
||
## 第 227-246 行:`shouldAppendJSONSuffix` 方法
|
||
|
||
```go
|
||
func (c *Client) shouldAppendJSONSuffix(path string) bool {
|
||
// 1. 如果设置了 SkipJSONSuffix,不添加
|
||
if c.SkipJSONSuffix {
|
||
return false
|
||
}
|
||
// 2. 如果已经有 .json 后缀,不添加
|
||
if strings.HasSuffix(path, ".json") {
|
||
return false
|
||
}
|
||
// 3. 如果是 raw 内容路径,不添加
|
||
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||
for i, part := range parts {
|
||
if part == "raw" && i >= 2 && i+2 < len(parts) {
|
||
return false
|
||
}
|
||
}
|
||
// 4. 其他情况,添加 .json 后缀
|
||
return true
|
||
}
|
||
```
|
||
|
||
**字面意思**:判断是否应该添加 `.json` 后缀
|
||
|
||
**运行时作用**:控制是否自动添加 `.json` 后缀。
|
||
|
||
**小白补充**:
|
||
|
||
为什么需要这个方法?
|
||
- Wiki Gateway API 不需要 `.json` 后缀(设置 `SkipJSONSuffix: true`)
|
||
- 某些路径(如 `/owner/repo/raw/...`)返回的是原始文件内容,不是 JSON
|
||
|
||
---
|
||
|
||
## 完整调用流程
|
||
|
||
```
|
||
ctx.CallAPI("GET", "/users/me", nil)
|
||
↓
|
||
Client.Do("GET", "/users/me", nil, nil)
|
||
↓
|
||
1. 路径处理:/users/me → /users/me.json
|
||
2. 构建 URL:https://www.gitlink.org.cn/api/users/me.json
|
||
3. 创建 HTTP 请求:http.NewRequest("GET", url, nil)
|
||
4. 发送请求:c.HTTP.Do(req)
|
||
↓ (auth.NewHTTPClient() 自动添加 Authorization 头)
|
||
5. 读取响应体:io.ReadAll(resp.Body)
|
||
6. 检查状态码:如果 >= 400,返回 APIError
|
||
7. 解析 JSON:json.Unmarshal → map[string]interface{}
|
||
8. 检查业务错误:判断 status 字段
|
||
9. 返回 Envelope:output.SuccessEnvelope(raw, meta)
|
||
```
|