NEW

免费试用已开放

立即开始

API · SDK · 文档与调试

Go 接入企业微信 API

更新于 2026-08-169 分钟

Go 写企业微信接入服务有个反直觉的地方:goroutine 太便宜,导致大多数人一上来就把并发开满,然后在生产环境撞上三件事 —— 连接池根本没配、超时没有预算概念、回调里派生出去的 goroutine 拿着一个已经被取消的 context。这三件都不是业务逻辑写错了,是 Go 的默认值和企业微信这类外部依赖的特性对不上。下面按 wecomapi 的 REST 接入方式,讲 Go 侧真正要动手改的几个地方。

先决定要不要自己封一层

搜「企业微信 Golang SDK」的人,多半想找一个装上就能用的包。现实是这类接入以 REST 为主,Go 侧用标准库直接发请求就能跑通第一条链路,要不要一层封装是你自己的工程决定,不是接入的前置条件。企业微信go接入卡住的地方从来不在有没有 SDK。

该不该封的通用判断线 —— 按调用点数量而不是接口数量来定、再按团队规模分档 —— 站内讲 SDK 选型那篇已经给全,这里不重复;下面只说 Go 这侧真要封的时候,该封成什么形状。

真要封,封的也不该是「每个接口一个方法」的全量镜像。那个东西跟着文档变,文档一改你就得全线返工,而它换来的只是少写几行结构体。该封的是横切关注点:鉴权头注入、超时与重试预算、限速、错误分类、留痕。一个持有 *http.Client 的 client struct 加一个统一的 do 方法,各接口只是薄薄一层参数结构体,改起来才不疼。

  • 值得封:client 复用、Authorization 注入、超时与重试预算、错误分类、请求标识落日志
  • 不值得封:把每个接口镜像成一个方法名,字段一变全线返工
  • 不该封:业务语义。在这一层判断「这条消息该不该发」,等于把业务藏进了基础设施里

http.Client 只该有一个,而且默认值要改

Go 的坑在这里最集中,而且都长得很无辜。http.DefaultClient 没有超时,一次网络异常能让 goroutine 挂到进程重启;每次调用现 new 一个 &http.Client{} 更糟,Transport 不共享等于连接池不存在,高并发下全部时间花在 TLS 握手上。

正确形态是整个进程一个 client,Transport 显式配置。最该动的是 MaxIdleConnsPerHost —— 标准库默认只有 2,你开两百个 goroutine 打同一个域名,其中一百九十八个在新建连接。症状是延迟毛刺加大量 TIME_WAIT,看起来像对方慢,其实是自己没配。

  • MaxIdleConnsPerHost 提到和你的并发上限同量级,别留默认值
  • IdleConnTimeout 要小于对端回收空闲连接的时间,否则你会周期性地拿到「用已被关闭的连接发请求」这类偶发失败
  • client.Timeout 是兜底不是主控。它覆盖整个请求(含响应体读取),细粒度控制一律交给 context

多账号场景下有个常见的过度设计:按账号各建一个 client。实例之间要隔离的是登录态和数据,HTTP 连接不需要跟着隔离 —— 对 wecomapi 这类统一网关来说它们本来就是同一个域名,共用连接池反而让复用率更高、握手更少。要隔离的是限速器,不是连接池,这两件事经常被一起做掉。

超时要分三层预算,不是设一个数

超时有三层:一次业务处理的总预算(比如一个事件进来后 30 秒内必须有结果)、一次外部调用的预算、一次重试所占的预算。绝大多数代码只有中间那层,于是重试三次的最坏耗时是三倍单次超时,早就穿透了总预算 —— 而上游那边可能十秒前就放弃了,你还在认真地打第三次。

做法是 context 在入口派生一次,往下每层只从剩余时间里切,不再各自 WithTimeout 一个固定值。重试之前先看剩余预算够不够跑完一次调用,不够就直接把上一次的错带出去,别把那个注定超时的请求发出去 —— 它既白占一次在途请求又拖长响应,还会在链路本来就慢的时候火上浇油。

示意:重试活在预算之内,退避能被取消go
// 示意逻辑:端点与字段仅作演示,精确定义以线上文档为准
// Payload 只有三个字段:Guid / ToId / Content
func (c *Client) SendText(ctx context.Context, p Payload) error {
    var last error
    for attempt := 0; attempt < c.maxAttempts; attempt++ {
        // 先看总预算还够不够跑完一次调用,不够就别发出去
        left, ok := budgetLeft(ctx)
        if !ok || left < c.minPerCall {
            return fmt.Errorf("budget exhausted after %d attempts: %w", attempt, last)
        }

        per := minDur(left-c.reserve, c.maxPerCall) // 给收尾留一点
        callCtx, cancel := context.WithTimeout(ctx, per)
        err := c.do(callCtx, "https://manager.wecomapi.com/message/sendText", p)
        cancel() // 循环里要立刻释放;defer 会攒到函数返回才执行

        if err == nil || !retryable(err) {
            return err
        }
        last = err

        // 退避也必须能被取消 —— time.Sleep 看不见 ctx
        select {
        case <-time.After(backoff(attempt)):
        case <-ctx.Done():
            return ctx.Err()
        }
    }
    return last
}
  • defer cancel() 写在循环体里,会一直攒到函数返回才执行,长循环下就是 context 泄漏
  • 退避用 time.Sleep 等于让这段时间不可中断,要用 select 同时等定时器和 ctx.Done()
  • 退避算法本身和批量任务的打散,站内讲频控与重试那篇展开过,这里只强调它必须活在预算之内

并发扇出:限并发容易,错误传播才是要想清楚的

批量发消息的默认写法是 for 加 go,一万条瞬间出去。goroutine 本身撑得住,撑不住的是对端和你自己的连接池。所以扇出永远配一个信号量,容量按你愿意承担的在途请求数定 —— 不要按 CPU 核数推,这是 IO 密集,核数在这里没有任何参考价值。

更容易做错的是错误传播语义。errgroup.WithContext 的行为是第一个非 nil 错误就取消整组 context,后面的调用全部提前失败。对「批量查询,缺一不可」这是对的;对「给三千个客户发通知」这是灾难 —— 第 7 条因为参数问题失败,剩下两千九百多条被 context 取消,其中一部分请求其实已经发出去了,而你连哪些发了都分不清。

  • 读操作、全成功才有意义:fail-fast,用 errgroup.WithContext,第一个错就收工
  • 写操作、部分成功也有价值:收集错误而不是取消,每条结果单独落状态,事后只补失败的那些
  • 无论哪种,外层都要有一个总的 context 超时,否则一个卡死的调用能把整批一起拖住

还有一对经常被同一个信号量凑合掉的东西:限并发和限速。信号量约束的是同时在途的请求数,限速约束的是单位时间发出的请求数,后者才是频控真正关心的量。对 wecomapi 这类按账号维度托管的接入,限速器要挂在账号上而不是挂在进程上 —— 挂在进程上的话,多副本部署一上线,实际速率就是副本数乘以你以为的那个值,而你在代码里一个字都没改。

回调服务端:r.Context() 在你 ACK 之后就死了

这是 Go 接入里最贵的一个坑,贵在它完全符合「先快速 ACK 再异步处理」这条正确做法,只是写法错了。handler 里返回 200,然后 go process(r.Context(), evt) —— HTTP 请求一结束,标准库就取消了这个 context,后台 goroutine 拿到的是一个已经 Done 的 ctx,所有下游调用立刻返回 canceled。

它的可怕之处在于测试期完全正常:消息少、处理快,很多时候在取消生效前就跑完了。上量之后变成大面积失败,日志里全是 context canceled,看着像网络抖动,实际上一个字节都没发出去过。

示意:入口只做三件事,业务在 worker 里跑go
func (s *Server) callback(w http.ResponseWriter, r *http.Request) {
    evt, err := s.parse(r) // 验签与解析,事件结构以线上文档为准
    if err != nil {
        w.WriteHeader(http.StatusUnauthorized)
        return
    }

    // ACK 之前先把原始报文可靠落下来;落失败就别 ACK,让平台按重试策略重投
    if err := s.inbox.Append(r.Context(), evt); err != nil {
        w.WriteHeader(http.StatusInternalServerError)
        return
    }
    w.WriteHeader(http.StatusOK) // 快速 ACK,到此为止
}

// 业务处理在 worker 里,context 从进程级派生。
// 绝不能带 r.Context() 进来 —— 它在 handler 返回那一刻就被取消了。
func (s *Server) worker(base context.Context) {
    defer s.wg.Done() // http.Server.Shutdown 不管这些 goroutine,得自己等
    for evt := range s.inbox.Consume(base) {
        ctx, cancel := context.WithTimeout(base, s.processBudget)
        s.process(ctx, evt)
        cancel()
    }
}

派生的正确来源是进程级的长生命周期 context:main 里建一个,收到退出信号时取消,异步任务从它派生并各配自己的超时。如果只是想保留请求上携带的链路值而丢掉取消信号,用 context.WithoutCancel 派生一层,比自己造一个空 context 干净。

  • ReadHeaderTimeout 必须显式设。Go 的 http.Server 默认不限,慢连接能一直占着
  • WriteTimeout 只要大于你的 ACK 时间即可,处理已经不在请求生命周期里了
  • body 用 http.MaxBytesReader 限一下大小,别让一个异常报文把内存吃掉
  • 退出信号加 WaitGroup 一起用。不等在途任务就发版,每次都会丢掉一批处理到一半的事件

上线前的 Go 侧自查

  • 全进程一个 *http.Client,MaxIdleConnsPerHost 与 IdleConnTimeout 都显式配过
  • 所有对外调用都接收 context,代码里没有裸的 http.Get 和裸的 time.Sleep
  • 重试循环检查剩余预算,cancel 不在循环里 defer,退避能被取消
  • 扇出有信号量,fail-fast 还是收集错误是想清楚后选的,不是默认来的
  • 限速器挂在账号维度并且能热调,多副本部署时算过实际总速率
  • 回调 handler 里没有 go func 直接带 r.Context(),异步任务从进程级 context 派生
  • 有退出信号和 WaitGroup,发版时在途任务能跑完再退

这几条里前两条半小时就能改完,后面几条要动结构。判断哪些必须在上线前解决的标准只有一条:会不会造成静默失败。连接池配得差只是慢,监控上看得见;而 context 被提前取消、goroutine 在发版时被砍掉,都是不响的 —— 等你发现的时候,已经丢了一批数据,而且补不回来。

本文讲的是 Go 侧的工程做法与取舍,不涉及具体字段。接口的精确字段名、错误码与端点路径以 wecomapi 线上接口文档为准,示意代码不要照抄上生产。

常见问题

有官方的企业微信 Golang SDK 吗?
这类接入以 REST 为主,Go 侧用标准库直接发 HTTP 请求就能跑通第一条链路,不依赖特定 SDK;wecomapi 的接口约定与调用示例见线上文档。要不要在自己项目里封一层,看调用点和调用方的数量:三五个接口、一个调用方不值得封,接口过十个或者这层要交给别的团队用才划算。
并发数应该设多少?
别从 CPU 核数推,这是 IO 密集型任务,本机算力不是瓶颈。把并发上限做成可热调的配置,从小往上加,同时盯被拒率和端到端 P99。另外要把限并发和限速分开:前者管同时在途的请求数,后者管单位时间的请求数;在 wecomapi 这类按账号托管的形态下,限速器要挂在账号维度上,否则多副本部署时实际速率会翻倍。
回调里派生的 goroutine 全部报 context canceled 是怎么回事?
请求 context 会在 handler 返回后立刻取消,带着它派生后台任务,任务一启动就已经是取消状态。修法是从进程级 context 派生并配自己的超时;只需要透传链路值时用 context.WithoutCancel。同时别忘了另一半:ACK 之前先把原始报文可靠落盘,落失败返回非 2xx 让平台重投,否则你只是把「处理失败」换成了「静默丢失」。

准备好动手了?

精确字段、鉴权与端点以线上文档为准;可在控制台创建密钥后联调。

相关文章