gitlink-cli/doc/reading_notes/02_client.md

16 KiB
Raw Blame History

internal/client/client.go 阅读笔记(面向 Go 小白)


第 1 行:package client

字面意思:声明这个文件属于 client

运行时作用:这是项目的 HTTP 客户端模块,负责所有与 GitLink API 的通信。


第 3-16 行import 导入依赖

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 结构体(核心!)

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 结构体

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() 方法:

func (e *APIError) Error() string {
    return fmt.Sprintf("[%v] %s", e.Code, e.Message)
}
  • 这是实现了 Go 的 error 接口
  • 任何实现了 Error() string 方法的类型都可以作为 error 返回
  • 这样 APIError 就可以像普通错误一样使用:return apiErr

② 为什么需要自定义错误类型?

普通的 error 只能包含一条消息,而我们需要:

  • StatusCodeHTTP 状态码404/403/500 等)
  • CodeAPI 返回的业务错误码
  • Kind:错误分类(认证错误/输入错误/服务器错误等)
  • Suggestion:给用户的解决建议

第 37-46 行:New 函数(构造函数)

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,内容大致如下:

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 行)

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.BaseURLhttps://www.gitlink.org.cn/api
  • path/users/me
  • 最终 fullURL 变成 https://www.gitlink.org.cn/api/users/me.json

② 请求体处理(第 69-77 行)

// 处理请求体
var bodyReader io.Reader
if body != nil {
    // 把 body 序列化成 JSON
    data, err := json.Marshal(body)
    if err != nil {
        return nil, err
    }
    // 转成 io.ReaderHTTP 请求需要的格式)
    bodyReader = bytes.NewReader(data)
}

字面意思:把请求体转成 HTTP 可以发送的格式

运行时作用:如果有请求体(如 POST/PUT 请求),把 Go 的 map 转成 JSON 字符串,再转成字节流。

小白补充

  • json.Marshal(body):把 Go 结构体/map 转成 JSON 字节数组
  • bytes.NewReader(data):把字节数组包装成 io.ReaderHTTP 请求体需要这个接口)

③ 创建 HTTP 请求(第 79-87 行)

// 创建 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 行)

// 发送请求
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.BodyHTTP 连接池会被占满,导致后续请求失败

⑤ 读取响应体(第 94-101 行)

// 读取响应体
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 行)

// 检查 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 行)

// 解析 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 业务错误(响应体中的 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 的响应格式:

{
    "status": 0,      // 0=失败, 1=成功, 200=成功
    "message": "...", // 错误信息
    "data": {...}     // 数据
}

⑨ 自动解析 JSON 字符串数据(第 144-150 行)

// 自动解析 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需要再次解析。

小白补充

比如响应是这样的:

{
    "status": 1,
    "data": "{\"name\": \"test\"}"  // data 是字符串!
}

这里需要把 "{\"name\": \"test\"}" 再解析成 {"name": "test"}

⑩ 构建分页元数据(第 152-166 行)

// 构建分页元数据
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 行:便捷方法

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 行:错误信息映射

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 方法

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. 构建 URLhttps://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. 解析 JSONjson.Unmarshal → map[string]interface{}
8. 检查业务错误:判断 status 字段
9. 返回 Envelopeoutput.SuccessEnvelope(raw, meta)