Skip to content

⚙️ 工作原理

一次 client.GetIPInfo(ctx, "8.8.8.8", "json") 调用,内部究竟发生了什么?

🧱 整体架构

💡 一图抵千言

上面这张 Mermaid 流程图展示了从调用到返回的完整链路。鼠标悬停节点可看说明,支持缩放。

🔁 一次调用的完整流程

GetIPInfo 为例(源码):

步骤 1:前置校验

go
if err := ValidateIP(ip); err != nil {
    return nil, c.handleError(err)   // 非法 IP,不发请求
}
if err := ValidateFormat(format); err != nil {
    return nil, c.handleError(err)   // 非法格式,不发请求
}

ValidateIPnet.ParseIP 判断;ValidateFormatvalidFormats 白名单。校验失败直接返回,省一次网络往返。

步骤 2:构建请求

go
req, err := newGetRequest(ctx, c.BaseURL, ip, format)

newGetRequestBaseURL + 路径段用 path.Join 拼成 https://ipapi.co/8.8.8.8/json/,并附上 context

步骤 3:注入认证与回调

go
c.applyAuth(req)
c.setHeaders(req)

applyAuth 根据 APIKeyMode 决定:

  • APIKeyHeader(默认)→ Authorization: Bearer <key>
  • APIKeyQuery?key=<key>

若有 Callback,追加 ?callback=<name>(用于 JSONP)。

setHeaders 设置 User-Agent

步骤 4:执行请求(核心)

go
resp, err := c.doRequest(req)

doRequest 做三件事:

  1. 限流:若 RateLimiter 通道非空,阻塞等待令牌。
  2. 重试:循环 0..Retries,仅在网络错误或 StatusCode >= 500 时重试,间隔 defaultRetryDelay(500ms)。
  3. 错误映射:若 StatusCode >= 400,尝试解码 APIError;否则用 mapStatusCodeToError 兜底。

步骤 5:解析响应

go
var info IPInfo
json.NewDecoder(resp.Body).Decode(&info)
info.RetrievedAt = time.Now().UTC()
return &info, nil

JSON 解码进强类型 IPInfo,并盖上 RetrievedAt 时间戳。

若是 raw 方法(GetIPInfoRaw 等),则 io.ReadAll 返回原始 []byte

步骤 6:统一错误出口

所有错误都经过 handleError

  • 若设了自定义 errorHandler,先调用它。
  • 否则用 errors.As 解包 APIError,按 Reason 映射到哨兵错误。

🎯 错误出口的设计意图

所有 6 个查询方法的错误都汇入 handleError 这一个出口。好处是:调用方只需学一套 errors.Is 判别逻辑,不论调哪个方法;自定义 errorHandler 能在统一位置插桩日志、上报、脱敏,无需改每个调用点。

🔑 关键设计决策

为什么用函数式选项?

NewClient(opts ...ClientOption) 让默认值与覆盖分离:

  • 想用默认 → NewClient()
  • 想配 API Key → NewClient(WithAPIKey(k))
  • 想换 HTTP 客户端 → 再加一个 WithCustomHTTPClient(...)

可组合、可扩展,不破坏向后兼容。

为什么错误用哨兵值 + errors.Is

Go 惯用法。调用方可以:

go
switch {
case errors.Is(err, ipapi.ErrRateLimited): // 限流
case errors.Is(err, ipapi.ErrInvalidIP):   // 非法 IP
}

同时 APIError 携带 ReasonIPReserved 等上下文,需要细节时用 errors.As 取出。

为什么限流用 <-chan time.Time

通道天然支持并发安全阻塞。塞一个 time.Tick(...) 进去就是固定速率;塞自定义通道就能做令牌桶、动态调速。比锁简单,比接口轻量。

📐 通道 vs 锁 vs 接口

方案并发安全灵活度复杂度
<-chan time.Time天然高(任意生产者)
sync.Mutex需手写
Limiter 接口需实现
通道方案同时拿到并发安全与可替换性,是 stdlib-only 下的最优解。

为什么 raw 方法返回 []byte

XML/CSV/YAML/JSONP 没有统一的 Go 结构体,强行解析反而丢失信息。返回原始字节让调用方按需处理,最灵活。

🧩 模块划分

文件职责
client.goClient 结构体、构造、选项、常量
api.go6 个查询方法、请求构建、重试、状态码映射
models.goIPInfoAPIError 数据模型与辅助方法
errors.gohandleErrorIsRetryableErrorWrapError

下一步

基于 MIT 许可证发布