Go SDK
Go SDK 让你的业务服务端用 Go 调用服务端 REST API(下文简称 OpenAPI)、接收事件回调和同步回调。本页介绍安装、创建客户端和全部配置项、第一个调用,以及 SDK 怎样获取和缓存 App Token、怎样用 context 控制每次调用的时限。
安装
go get github.com/deeprespond/im/sdk/server/go@latest- 需要 Go 1.23 以上:遍历方法(如
client.Users.All)返回 Go 1.23 的迭代器iter.Seq2,用for ... range遍历。 - 只依赖标准库,不引入第三方模块。
- 模块中有三个包:
| 包 | 导入路径 | 用途 |
|---|---|---|
drim | github.com/deeprespond/im/sdk/server/go | 调用 OpenAPI:客户端、各服务的方法、参数和结果类型、错误 |
callback | github.com/deeprespond/im/sdk/server/go/callback | 接收回调:校验签名、防重放、去重、分派事件和同步回调 |
drimtest | github.com/deeprespond/im/sdk/server/go/drimtest | 测试辅助:假的 OpenAPI 服务、带签名的回调请求、签名测试向量 |
模块路径的最后一段是 go,包名是 drim,导入时习惯写上包名:
import drim "github.com/deeprespond/im/sdk/server/go"只在服务端使用
Client Secret 和 App Token 等同应用管理员的密码:可以创建和删除用户、以任何用户的身份发消息。不要把它们写进代码仓库、App 或网页,客户端请使用 Web SDK 等客户端 SDK。
创建客户端
package main
import (
"log"
"os"
drim "github.com/deeprespond/im/sdk/server/go"
)
func main() {
client, err := drim.New(drim.Config{
BaseURL: "https://im.example.com",
OrgName: "1100250925",
AppName: "demo",
ClientID: os.Getenv("IM_CLIENT_ID"),
ClientSecret: os.Getenv("IM_CLIENT_SECRET"),
})
if err != nil {
log.Fatal(err) // 配置不合规,如 BaseURL 不是 https 地址
}
defer client.Close()
// 把 client 交给你的业务代码使用……
}- 一个客户端对应一个应用:
OrgName和AppName即 AppKey(org_name#app_name)中#前后的两部分,在控制台的应用详情中查看;Client ID 和 Client Secret 见鉴权与 App Token。同一进程管理多个应用时,为每个应用各建一个客户端。 - 在进程中复用:客户端可以被多个协程同时使用,内部共享 App Token、连接池和限流等待。请在进程启动时创建一个,不要每个请求新建。
- 创建时不发请求:
New只检查配置,第一次调用接口时才换取 App Token。配置不合规时返回普通的error(不是*drim.Error)。 Close:停止后台的令牌换取,关闭 SDK 自己创建的 HTTP 客户端的空闲连接。之后的调用返回client_unavailable错误。
New 检查的内容:
BaseURL必须是http或https的绝对地址,不能带查询参数、片段、用户名和密码;末尾的/会去掉。可以带路径前缀,如经过网关转发的https://gw.example.com/im。http://只允许本机(localhost、127.0.0.0/8、::1)和内网地址(10.0.0.0/8、172.16.0.0/12、192.168.0.0/16),其他地址必须用https://,避免凭据以明文发出。OrgName、AppName、ClientID、ClientSecret不能为空;时长和次数不能为负数;设置了RateLimit时PerSecond必须大于 0。
配置项
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
BaseURL | string | 是 | IM 服务的地址,如 https://im.example.com |
OrgName | string | 是 | 租户的唯一标识,即 AppKey 中 # 之前的部分 |
AppName | string | 是 | 应用名称,即 AppKey 中 # 之后的部分 |
ClientID | string | 是 | 应用的 Client ID |
ClientSecret | string | 是 | 应用的 Client Secret |
HTTPClient | *http.Client | 否 | 发请求用的 HTTP 客户端,见下文 |
Timeout | time.Duration | 否 | 每次尝试的时限(从发出请求到读完响应),默认 30 秒。完成上传固定为 150 秒,不受它影响 |
Retry | drim.RetryPolicy | 否 | 自动重试的设置,见下文 |
MaxRetryAfter | time.Duration | 否 | 被限流时自动等待的 Retry-After 上限,默认 60 秒;超过的不等待,直接返回 rate_limited |
RateLimit | *drim.RateLimit | 否 | 本地限速,默认关闭,见限流 |
TokenStore | drim.TokenStore | 否 | 多个服务实例共用一个 App Token 时的共享存储,默认只在进程内存中,见多个实例共用 App Token |
Logger | *slog.Logger | 否 | 日志,默认不输出,见日志与排查 |
Hooks | drim.Hooks | 否 | 请求前后的钩子,用于注入追踪头、记录指标,见日志与排查 |
UserAgentSuffix | string | 否 | 附加到 User-Agent 之后,如 order-svc/2.3。SDK 的 User-Agent 为 deeprespond-im-server-sdk/go/{版本} |
HTTP 客户端
不设置 HTTPClient 时,SDK 自己创建一个:每个主机最多 100 个空闲连接,空闲 90 秒关闭,连接超时和 TLS 握手各 10 秒,使用环境变量中的代理(HTTPS_PROXY 等),不跟随重定向(OpenAPI 不会重定向,收到 3xx 按 invalid_response 处理)。Close 时关闭它的空闲连接。
需要自定义时(如私有化部署使用自签名证书、不走代理),传入自己的 *http.Client。SDK 不修改也不关闭它;不要设置 http.Client.Timeout,每次尝试的时限由 SDK 按 Timeout 控制(完成上传需要 150 秒)。
package main
import (
"crypto/tls"
"crypto/x509"
"log"
"net/http"
"os"
"time"
drim "github.com/deeprespond/im/sdk/server/go"
)
func newClient() (*drim.Client, error) {
// 私有化部署:信任自己的 CA 证书(SDK 没有跳过证书校验的选项)
pem, err := os.ReadFile("/etc/im/ca.pem")
if err != nil {
return nil, err
}
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(pem)
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil // 内网直连,不走代理
transport.TLSClientConfig = &tls.Config{RootCAs: pool}
return drim.New(drim.Config{
BaseURL: "https://im.internal.example.com",
OrgName: "1100250925",
AppName: "demo",
ClientID: os.Getenv("IM_CLIENT_ID"),
ClientSecret: os.Getenv("IM_CLIENT_SECRET"),
HTTPClient: &http.Client{Transport: transport},
Timeout: 10 * time.Second,
})
}
func main() {
client, err := newClient()
if err != nil {
log.Fatal(err)
}
defer client.Close()
}重试设置
Retry 的字段:
| 字段 | 类型 | 说明 |
|---|---|---|
MaxAttempts | int | 网络错误、超时、5xx 时的最多尝试次数(含第一次),默认 4,即最多重试 3 次;1 表示不重试 |
MaxRateLimited | int | 收到 rate_limited 后等待重发的次数,默认 3 |
Disabled | bool | 为 true 时两种都不重试。App Token 失效后换取新令牌再重发一次不受影响 |
哪些请求会自动重试、怎样重试,见重试与幂等。
第一个调用
签发登录凭证
推荐的登录方式是登录凭证:用户登录你的业务系统后,业务服务端为他签发一个 IM 登录凭证,App 用客户端 SDK 以凭证登录(5 分钟内有效、只能用一次),见签发登录凭证。
下面是业务服务端提供给 App 的“取 IM 登录凭证”接口。AutoCreate: true 时用户不存在会自动创建,业务系统的用户 ID 直接作为 IM 的用户名:
package main
import (
"encoding/json"
"log"
"net/http"
"os"
drim "github.com/deeprespond/im/sdk/server/go"
)
func main() {
client, err := drim.New(drim.Config{
BaseURL: "https://im.example.com",
OrgName: "1100250925",
AppName: "demo",
ClientID: os.Getenv("IM_CLIENT_ID"),
ClientSecret: os.Getenv("IM_CLIENT_SECRET"),
})
if err != nil {
log.Fatal(err)
}
defer client.Close()
// App 登录业务系统后调用这个接口取 IM 的登录凭证;凭证无效时 App 会再取一次,这里要允许重复调用
http.HandleFunc("POST /api/im-ticket", func(w http.ResponseWriter, r *http.Request) {
userID := currentUser(r) // 由业务系统的登录态得出
ticket, err := client.Users.IssueLoginTicket(r.Context(), userID, drim.IssueLoginTicketParams{AutoCreate: true})
if drim.IsCode(err, drim.CodeUserDisabled) {
http.Error(w, "账号已被封禁", http.StatusForbidden)
return
}
if err != nil {
log.Printf("签发 IM 登录凭证失败:%v", err) // 错误信息中不含凭证和令牌
http.Error(w, "暂时不可用", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ticket": ticket.Ticket, "expires_in": ticket.ExpiresIn})
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
func currentUser(r *http.Request) string { return r.Header.Get("X-User-ID") }- 每次签发得到一个新凭证(
ult_开头),之前签发的在有效期内仍可使用,所以 SDK 会自动重试这个请求;只把最后拿到的交给 App。 ticket.UserCreated表示这次是否创建了用户。- 凭证相当于临时密码,只经 HTTPS 返回给这个用户的 App,不要写入日志。
发送消息
以业务的通知账号给用户发一条单聊消息:
package main
import (
"context"
"log"
"os"
"time"
drim "github.com/deeprespond/im/sdk/server/go"
)
func main() {
client, err := drim.New(drim.Config{
BaseURL: "https://im.example.com",
OrgName: "1100250925",
AppName: "demo",
ClientID: os.Getenv("IM_CLIENT_ID"),
ClientSecret: os.Getenv("IM_CLIENT_SECRET"),
})
if err != nil {
log.Fatal(err)
}
defer client.Close()
// 整次调用(含重试和限流等待)最多 10 秒
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
body, err := drim.Marshal(map[string]any{"card": "order", "order_id": "1001", "title": "订单已支付"})
if err != nil {
log.Fatal(err)
}
result, err := client.Messages.Send(ctx, drim.SendMessageParams{
From: "notice",
ToUser: "zhangsan",
ClientMsgID: "order-1001-paid", // 业务单号作为去重 ID:业务层重试也不会重复发送
Type: "custom",
Body: body,
Push: &drim.MessagePushOptions{Title: "订单通知", Body: "您的订单已支付"},
})
if err != nil {
log.Fatal(err)
}
if result.Duplicate {
log.Printf("重复提交,返回的是第一次发送的消息 %s", result.MessageID)
}
log.Printf("已发送:会话 %s,序号 %d", result.ConversationID, result.Seq)
// 群聊:不给 From 时以系统身份发送
_, err = client.Messages.Send(ctx, drim.SendMessageParams{
ToGroup: "1840012345678901",
Type: "text",
Body: drim.JSON(`{"text":"今晚 8 点系统维护"}`),
})
if err != nil {
log.Print(err)
}
}- 消息内容
Body是json.RawMessage,格式见消息格式。用drim.JSON直接写 JSON 文本,或用drim.Marshal编码 Go 的值(它不把<、>、&转义为<等,消息大小按发出的字节计算)。 - 发送的参数和返回值见发送消息;
ClientMsgID的作用见重试与幂等。
App Token 的获取与缓存
调用 OpenAPI 需要 App Token(见鉴权与 App Token)。SDK 用 Client ID 和 Client Secret 自动换取、缓存和续期,你不需要自己调用换取令牌的接口:
- 第一次调用时换取:令牌保存在客户端的内存中,之后的请求都带上它。
- 同时只换取一次:多个协程同时需要新令牌时,SDK 只发一个换取请求,其他请求等待它的结果(换取请求按来源 IP 限流,也计入应用的 OpenAPI 额度)。
- 提前续期:令牌的剩余有效期不到 10%(最少提前 5 分钟)时,下一次请求在后台换取新令牌,换取期间仍用旧令牌,请求不必等待。剩余不足 1 分钟时,请求等待换取完成再发出。长时间没有请求时不会主动续期。
- 到期时刻按本机时间计算:取“发出换取请求的时刻 +
expires_in”。本机时钟与服务端相差较大时,令牌可能提前失效,由下面的“令牌失效”兜底。 - 令牌失效:OpenAPI 返回
unauthenticated且没有details.reason时(Secret 被吊销或过期、应用停用等),SDK 丢弃这个令牌,换取一次新令牌后把这个请求重发一次;换取失败或重发仍是unauthenticated时,把错误返回给你。
换取失败
| 换取的结果 | SDK 的处理 |
|---|---|
invalid_client(凭据错误或应用不存在)、ip_not_allowed(来源 IP 不在白名单) | 返回这个错误并记错误日志;之后 1 分钟内的请求直接返回 client_unavailable,不再换取,避免错误的配置耗尽来源 IP 的换取额度 |
tenant_unavailable、app_unavailable(服务暂停) | 返回这个错误;之后随机 2 到 5 分钟内的请求直接返回 client_unavailable,不再换取 |
rate_limited | 按 Retry-After 等待后再换取,最多 3 次 |
网络错误、5xx | 按 1、2、4 秒重试,最多 3 次 |
冷却期内返回的 client_unavailable 错误中,Details["cause"] 为最初的错误码,Details["until"] 为冷却结束的时刻。判断方法见错误类型与判断。
换取令牌的错误中 Op 为 app.token,而不是这次调用的接口名。
应用删除后客户端永久失效
OpenAPI 返回 unauthenticated 且 details.reason 为 app_deleted 或 tenant_closed 时,应用已被删除或租户已注销,不会恢复。SDK 把客户端标为永久失效:之后的调用都直接返回 client_unavailable,不再换取和重试,并记错误日志。用 drim.IsPermanentlyUnavailable(err) 判断,这时应清除保存的凭据。
轮换 Client Secret
在控制台轮换 Secret 后,旧 Secret 有 24 小时的宽限期(见鉴权与 App Token)。把服务的配置更新为新 Secret 后,调用 UpdateCredentials,或用新配置重新创建客户端:
// 配置中心推送了新的 Secret 之后
client.UpdateCredentials(os.Getenv("IM_CLIENT_ID"), os.Getenv("IM_CLIENT_SECRET"))UpdateCredentials 丢弃当前的令牌、结束换取失败后的冷却期,下一次请求用新凭据换取。它可以在客户端被其他协程使用时调用。
多个实例共用 App Token
多个服务实例各自换取令牌是允许的,同时有多个有效的 App Token 互不影响。实例很多、希望减少换取请求时,可以用 TokenStore 让它们共用一个令牌:
type TokenStore interface {
Get(ctx context.Context, key string) (token string, expiresAt time.Time, ok bool, err error)
Set(ctx context.Context, key string, token string, expiresAt time.Time) error
// CompareAndDelete 只在存储中的令牌等于 token 时删除(避免删掉别的实例刚换来的新令牌)
CompareAndDelete(ctx context.Context, key string, token string) error
}- 内存中没有令牌时,SDK 先从存储中读取(
Get返回ok为false时由本实例换取,换取后Set)。从存储中取到的令牌同样按“剩余不到 10% 时换取”续期,换取后写回。 - 令牌失效时,只有存储中的令牌仍是失效的那个才删除(
CompareAndDelete)。 - 存储的键形如
drim:token:加 16 个十六进制字符,由服务地址、org_name、app_name和 Client ID 算出,不含 Secret。 - 存储出错(或 5 秒内没有返回)时,SDK 退回本进程换取,不影响请求,并记警告日志。
SDK 只依赖标准库,没有内置 Redis 的实现。用 go-redis 的写法:
package tokenstore
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
drim "github.com/deeprespond/im/sdk/server/go"
)
// RedisTokenStore 在 Redis 中保存 App Token,值为 "{到期的毫秒}:{令牌}"。
type RedisTokenStore struct {
R *redis.Client
Prefix string
}
var _ drim.TokenStore = RedisTokenStore{}
func (s RedisTokenStore) Get(ctx context.Context, key string) (string, time.Time, bool, error) {
v, err := s.R.Get(ctx, s.Prefix+key).Result()
if errors.Is(err, redis.Nil) {
return "", time.Time{}, false, nil
}
if err != nil {
return "", time.Time{}, false, err
}
ms, token, ok := strings.Cut(v, ":")
n, err := strconv.ParseInt(ms, 10, 64)
if !ok || err != nil {
return "", time.Time{}, false, nil
}
return token, time.UnixMilli(n), true, nil
}
func (s RedisTokenStore) Set(ctx context.Context, key, token string, expiresAt time.Time) error {
value := fmt.Sprintf("%d:%s", expiresAt.UnixMilli(), token)
return s.R.Set(ctx, s.Prefix+key, value, time.Until(expiresAt)).Err()
}
var compareAndDelete = redis.NewScript(`
local v = redis.call('GET', KEYS[1])
if v then
local i = string.find(v, ':', 1, true)
if i and string.sub(v, i + 1) == ARGV[1] then return redis.call('DEL', KEYS[1]) end
end
return 0`)
func (s RedisTokenStore) CompareAndDelete(ctx context.Context, key, token string) error {
return compareAndDelete.Run(ctx, s.R, []string{s.Prefix + key}, token).Err()
}创建客户端时传入 TokenStore: RedisTokenStore{R: rdb, Prefix: "order-svc:"}。
取得当前的令牌
client.Token(ctx) 返回当前有效的 App Token(需要时换取),只用于排查,例如用 curl 手工调用一个接口。不要把它写入日志或交给客户端。client.AppID() 返回换取令牌时得到的应用 ID,还没换取过时为空字符串。
上下文与超时
每个方法的第一个参数是 context.Context:
ctx的截止时间是整次调用的总时限,包括自动重试、限流等待和等待换取令牌。等待会超过截止时间时,SDK 不再等待,直接返回错误。Config.Timeout是每次尝试的时限(默认 30 秒),从发出请求到读完响应。ctx被取消时返回错误码canceled,errors.Is(err, context.Canceled)为真;截止时间已到或某次尝试超时返回timeout,errors.Is(err, context.DeadlineExceeded)为真。- 在 HTTP 处理函数中,直接把
r.Context()传给 SDK:用户断开连接时,SDK 的调用随之取消。
单次调用的选项通过 ctx 传入,不改变方法的签名,只影响用这个 ctx 发出的调用(遍历方法的每一页都沿用):
| 函数 | 说明 |
|---|---|
drim.WithRequestID(ctx, id) | 作为请求头 X-Request-ID 发送你的追踪 ID,服务端原样沿用并写入日志,同一次调用的重试沿用同一个 ID。必须是 1 到 64 个字母、数字或 -、_、.,不合规的在本地以 invalid_params 拒绝 |
drim.WithAttemptTimeout(ctx, d) | 这次调用中每次尝试的时限,覆盖 Config.Timeout(也覆盖完成上传的 150 秒) |
drim.WithMaxAttempts(ctx, n) | 这次调用中网络错误、超时、5xx 的最多尝试次数(含第一次),1 表示不自动重试 |
drim.WithMaxRetryAfter(ctx, d) | 这次调用自动等待的 Retry-After 上限;0 表示被限流时不等待,直接返回 rate_limited |
func notifyPaid(ctx context.Context, client *drim.Client, orderID, username string) error {
// 整次调用最多 3 秒;带上业务的追踪 ID,服务端的日志中能按它查到这次调用
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
ctx = drim.WithRequestID(ctx, "order."+orderID)
// 线上请求被限流时不排队等待,交给业务的消息队列稍后再发
ctx = drim.WithMaxRetryAfter(ctx, 0)
_, err := client.Messages.Send(ctx, drim.SendMessageParams{
From: "notice",
ToUser: username,
ClientMsgID: "order-" + orderID + "-paid",
Type: "text",
Body: drim.JSON(`{"text":"您的订单已支付"}`),
})
return err
}频道版本
独立频道服务及九种强类型回调已写入当前源码,尚未发布本轮新 SDK 制品。安装旧版本时可能没有 Channels / channels 属性,请确认提供的版本包含频道方法,再按调用示例接入。
