OpenIM 整合(踩坑)筆記 01
OpenIM 號稱是由一群前微信等技術專家一起出來打造的開源 IM 系統。撇開政治不談,個人覺得微信在 IM 領域確實是佼佼者,因此決定整合 OpenIM 到現有的產品中,以減少浪費時間重複造輪子。
本篇目標是串連原有的帳號系統與 OpenIM 的帳號系統,完成 自動註冊用戶到 OpenIM (下圖中的第 3 ~ 4 步驟)。
文中:
open_im_api
就是 OpenIM API 服務open_im_auth
就是 OepnIM Auth 服務
System environment and Software version
# | Version |
---|---|
Container Infrastructure | K3s |
Open-IM-Server | v2 (commit: b978b6756e7558e3508bdb8bdd4ea3d7c51f79a1) |
Golang | 1.18.2 |
Operating System | CentOS 8 stream / Debian testing |
Installation
安裝部署主要參考 OpenIM 官方文件: k8s部署 ,並根據自身業務平台與習慣做調整。
第三方的軟體
OpenIM 需要一些第三方的軟體,包含: MySQL/MariaDB, MongoDB, Kafka, ETCD, Redis, … 等。 我主要是採用 helm 部署 bitnami 版本上去 (etcd 除外)。
部署的命令大致上是:
helm -n openim install infra01 bitnami/mariadb --values mariadb.yml
helm -n openim install infra02 bitnami/mongodb --values mongodb.yml
helm -n openim install infra03 bitnami/redis --values redis.yml
# helm -n openim install infra04 bitnami/zookeeper --values zookeeper.yml
helm -n openim install infra05 bitnami/kafka --values kafka.yml
bitnami/kafka 可以順便部署 zookeeper,請自行決定。
Etcd 則不適合用 bitnami 版本部署,個人猜測可能是因為 bitnami 有預設帳號, 但是 OpenIM 目前的版本還不支援 etcd 設置帳號,因此我在這裡先採用裸機部署,以後再找解決方案。
git clone -b v3.5.0 https://github.com/etcd-io/etcd.git
cd etcd/
./build.sh
./bin/etcd --listen-client-urls=http://10.60.31.91:22379 --advertise-client-urls=http://127.0.0.1:22379
OpenIM 配置
官方提供了 configmap.yaml
template,下載後按照自己的環境修改,再 apply 上去即可。以下重點提示幾個"坑":
- MySQL
mysql:
dbMysqlUserName: root #不能修改
dbMysqlPassword: ewjriwejirwe #root 密碼
MySQL 帳密必須是 root 帳密,否則一開始就無法運行。我猜測是建表的問題,但即使 grant all privileges
,依然無法解決。最後還是必須改回去 root。
- Manager Secret
manager:
#app管理员userID和对应的secret 建议修改。 用于管理后台登录,也可以用户管理后台对应的api
appManagerUid: [ "xxxxxxxxx" ]
secrets: [ "abc123456" ]
secret: arwsef234s9w
Call admin API 時,如果參數代入 manager.appManagerUid
以及 manager.secrets
(abc123456
),open_im_api
卻返回 401 not authorized
。這時候就需要將 manager.secrets
換成 secret
(arwsef234s9w
)。
部署 OpenIM
kubectl -n openim apply configmap.yaml
kubectl -n openim apply -f http://public.msypy.xyz/k8syamls/openim/latest/statefulsets/open_im_api.yaml
kubectl -n openim apply -f http://public.msypy.xyz/k8syamls/openim/latest/statefulsets/open_im_auth.yaml
kubectl -n openim apply -f http://public.msypy.xyz/k8syamls/openim/latest/statefulsets/open_im_user.yaml
如果要從 cluster 之外 call
open_im_api
, 記得要修改 service/api,將ClusterIP
改成LoadBalancer
。kubectl -n openim edit svc/api
或是設置 ingress 等,請按照自己的平台做設置。
整合開發
完整的程式碼就先不放了,現在也沒有空寫 demo,因此就將部分的業務程式碼修改一下放上來。
我的目標是:檢查用戶是否註冊,如果沒有,就幫用戶註冊帳號。因此需要 call: 查询用户是否在IM中已经注册 以及 注册新用户 這兩個 API。不過,查詢用戶是否註冊
,這個是管理 API,因此 http request 中必須要帶有 manager token,因此我們還需要先 call 换取管理员Token。
依照調用順序,以下展示關鍵的程式碼:
取得管理員 token
基本上這個 API 需要注意的就是 manager secret,如果代錯了,就會有 error。
package apis
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"github.com/google/uuid"
"go.uber.org/zap"
"io/ioutil"
"net/http"
"net/url"
)
const (
MANAGER_UID = "xxxxxxxxx"
MANAGER_SECRET = "abc123456"
OPENIM_API = "http://w.x.y.z:10000"
)
type adminToken struct {
UserId string `json:"userID"`
Token string `json:"token"`
ExpiredAt int64 `json:"expiredTime"`
}
type getAdminTokenRequest struct {
UserId *string `json:"userID"`
Platform int `json:"platform"`
RequestId uuid.UUID `json:"operationID"`
Secret *string `json:"secret"`
}
type getAdminTokenResponse struct {
Data adminToken `json:"data"`
ErrCode int `json:"errCode"`
ErrMsg string `json:"errMsg"`
}
func GetOpenIMAdminToken(logger *zap.Logger) (secret *string, err error) {
var (
api *url.URL
data []byte
resp *http.Response
)
/* 可以在這裡增加程式,從 redis 中讀取已存的 token */
_api := OPENIM_API + "/auth/user_token"
if api, err = url.Parse(api); err != nil {
logger.Error(
"parse openim api failed",
zap.Stringp("api", &api),
zap.Error(err),
)
return
}
payload := getAdminTokenRequest{
UserId: &MANAGER_UID,
Platform: 8,
RequestId: uuid.New(),
Secret: &MANAGER_SECRET,
}
data, _ = json.Marshal(payload)
_logger = logger.With(zap.String("operation_id", payload.RequestId.String()))
// logger.Debug("show request", zap.String("url", api.String()))
// fmt.Println(string(data))
if resp, err = http.Post(api.String(), "application/json", bytes.NewReader(data)); err != nil {
logger.Error(
"call openim api failed",
zap.String("url", api.String()),
zap.ByteString("payload", data),
zap.Error(err),
)
return
}
defer resp.Body.Close()
if data, err = ioutil.ReadAll(resp.Body); err != nil {
logger.Error(
"decode http response failed",
zap.Error(err),
)
return
}
// logger.Debug("show response")
// fmt.Println(string(data))
var response getAdminTokenResponse
if err = json.Unmarshal(data, &response); err != nil {
logger.Error(
"unmarshal openim response failed",
zap.ByteString("response", data),
zap.Error(err),
)
return
}
if response.ErrCode != 0 {
err = errors.New(response.ErrMsg)
logger.Error(
"openim api response error",
zap.Intp("code", &response.ErrCode),
zap.Error(err),
)
return
}
/* 可以在這裡增加程式,將 token 存入 redis */
secret = &response.Data.Token
return
}
查詢用戶是否註冊
這個 API 的重點是,要將 manager token 放到 header 中,而且設置對應的字段為 token
。其他應該沒什麼需要解釋的。
package apis
import (
"bytes"
"encoding/json"
"errors"
"github.com/google/uuid"
"go.uber.org/zap"
"io/ioutil"
"net/http"
"net/url"
)
type userRegistered struct {
UserID string `json:"userID"`
Status string `json:"accountStatus"`
}
type checkUserRegisteredRequest struct {
RequestId uuid.UUID `json:"operationID"`
UserList []*string `json:"checkUserIDList"`
}
type checkUserRegisteredResponse struct {
Data []userRegistered `json:"data"`
ErrCode int `json:"errCode"`
ErrMsg string `json:"errMsg"`
}
func CheckOpenIMUserRegistered(logger *zap.Logger, user_id *string) (registered bool, err error) {
var (
api *url.URL
client = http.Client{}
data []byte
req *http.Request
resp *http.Response
token *string
)
if token, err = GetOpenIMAdminToken(logger); err != nil {
return
}
_api := OPENIM_API + "/manager/account_check"
if api, err = url.Parse(api); err != nil {
logger.Error(
"parse openim api failed",
zap.Stringp("url", &api),
zap.Error(err),
)
return
}
payload := checkUserRegisteredRequest{
RequestId: uuid.New(),
UserList: []*string{user_id},
}
_logger := logger.With(
zap.Stringp("user", user_id),
zap.String("operation_id", payload.RequestId.String()),
)
data, _ = json.Marshal(payload)
req, _ = http.NewRequest("POST", api.String(), bytes.NewReader(data))
// req.Header.Set("Authorization", "Bearer "+*token)
req.Header.Set("token", *token)
req.Header.Set("Content-Type", "application/json")
if resp, err = client.Do(req); err != nil {
_logger.Error(
"check openim user registered failed",
zap.String("api", api.String()),
zap.ByteString("payload", data),
zap.Error(err),
)
return
}
defer resp.Body.Close()
if data, err = ioutil.ReadAll(resp.Body); err != nil {
_logger.Error("decode http response failed", zap.Error(err))
return
}
var response checkUserRegisteredResponse
if err = json.Unmarshal(data, &response); err != nil {
_logger.Error(
"unmarshal openim response failed",
zap.ByteString("response", data),
zap.Error(err),
)
return
}
if response.ErrCode != 0 {
err = errors.New(response.ErrMsg)
_logger.Error(
"check openim user registered status failed",
zap.Intp("code", &response.ErrCode),
zap.Error(err),
)
return
}
for _, data := range response.Data {
if data.UserID == *user_id {
registered = data.Status == "registered"
break
}
}
return
}
註冊用戶
myAccount
是我的內部資料結構。其他應該也不用解釋XDplatform
的數值可以參考: OpenIM字段说明
package apis
import (
"bytes"
"encoding/json"
"fmt"
"github.com/google/uuid"
"go.uber.org/zap"
"io/ioutil"
"net/http"
"net/url"
)
type registerUser struct {
ExpiredAt int64 `json:"expiredTime"`
Token string `json:"token"`
UserId string `json:"userID"`
}
type registerUserRequest struct {
// required fields
UserID *string `json:"userID"`
UserName *string `json:"nickname"`
Platform int `json:"platform"`
RequestId uuid.UUID `json:"operationID"`
Secret *string `json:"secret"`
// additional
AvatorURL *string `json:"faceURL,omitempty"`
Birthday int `json:"int"`
EMail *string `json:"email,omitempty"`
Ex *string `json:"ex,omitempty"`
Gender int `json:"gender"`
Phone *string `json:"phoneNumber,omitempty"`
}
type registerUserResponse struct {
Data registerUser `json:"data"`
ErrCode int `json:"errCode"`
ErrMsg string `json:"errMsg"`
}
func RegisterOpenIMUser(logger *zap.Logger, user *myAccount) (err error) {
var (
api *url.URL
data []byte
resp *http.Response
)
_api := OPENIM_API + "/auth/user_register"
if api, err = url.Parse(_api); err != nil {
logger.Error(
"parse openim api endpoint failed",
zap.Stringp("url", &_api),
zap.Error(err),
)
return
}
member := registerUserRequest{
UserID: &user.UserId,
UserName: &user.Name,
Platform: 7, // 0, 1, 7, ... 都可以
RequestId: uuid.New(),
Secret: &settings.OPENIM.Secret,
Gender: user.Gender,
}
_logger := logger.With(
zap.Stringp("user", &user.UserId),
zap.String("operation_id", member.RequestId.String()),
)
data, _ = json.Marshal(member)
if resp, err = http.Post(api.String(), "application/json", bytes.NewReader(data)); err != nil {
_logger.Error(
"register openim user failed",
zap.ByteString("payload", data),
zap.Error(err),
)
return
}
defer resp.Body.Close()
if data, err = ioutil.ReadAll(resp.Body); err != nil {
_logger.Error("decode http response failed", zap.Error(err))
return
}
var response registerUserResponse
if err = json.Unmarshal(data, &response); err != nil {
_logger.Error(
"unmarshal openim response failed",
zap.ByteString("response", data),
zap.Error(err),
)
return
}
if response.ErrCode != 0 {
err = errors.New(response.ErrMsg)
_logger.Error(
"register openim user failed",
zap.Intp("code", &response.ErrCode),
zap.Error(err),
)
}
return
}
Call 完之後就可以在資料庫中看到用戶註冊的資料了:
MariaDB [openIM_v2]> select * from users;
+--------------------------------------+-------------+----------+--------+--------------+---------------------+-------+------+---------------------+------------------+
| user_id | name | face_url | gender | phone_number | birth | email | ex | create_time | app_manger_level |
+--------------------------------------+-------------+----------+--------+--------------+---------------------+-------+------+---------------------+------------------+
| 85977fc1-e783-4696-9517-174ecea2e8be | Bill | | 1 | | 1970-01-01 08:00:00 | | | 2022-05-25 11:52:21 | 1 |
| transcend | AppManager1 | | 0 | | 1970-01-01 08:00:00 | | | 2022-05-25 09:12:55 | 2 |
+--------------------------------------+-------------+----------+--------+--------------+---------------------+-------+------+---------------------+------------------+
2 rows in set (0.006 sec)
Troubleshooting
802 db failed
Call API 時返回錯誤:
{
"errCode": 802,
"errMsg": "db failed",
"data": {
"userID": "xxxxxxxxx",
"token": "",
"expiredTime": 0
}
}
這個錯誤只是 open_im_api
自身返回的錯誤訊息,並不一定是後端真正遇到的問題。
目前遇到有以下兩種狀況, open_im_api
會返回 802 db failed
:
- 操作管理員用戶 (例如獲取 manager token), 但是 MySQL 裡面沒有 admin 的帳號
- Redis 密碼錯誤, 沒有建立連線
第一種狀況, 只需要把 admin 信息放回去,(理論上)就可以了:
+--------------------------------------+-------------+----------+--------+--------------+---------------------+-------+------+---------------------+------------------+
| user_id | name | face_url | gender | phone_number | birth | email | ex | create_time | app_manger_level |
+--------------------------------------+-------------+----------+--------+--------------+---------------------+-------+------+---------------------+------------------+
| xxxxxxxxx | AppManager1 | | 0 | | 1970-01-01 08:00:00 | | | 2022-05-25 09:12:55 | 2 |
+--------------------------------------+-------------+----------+--------+--------------+---------------------+-------+------+---------------------+------------------+
但是我這裡的做法比較暴力XD 因為我才剛開始,一點資料都沒有,所以我直接砍光所有的資料表,然後重跑一次 open_im_api
創建資料表
Redis: username-password pair or user is disabled
第二種狀況,我的情境是在 call /auth/user_token
返回 802
。經追查之後發現是 open_im_auth
有錯誤訊息:
原本以為是 OpenIM 寫死密碼/不支援密碼,後來才發現 configmap 裡設置的 redis 密碼不是正確的密碼。至於為什麼會有這個問題,這就是另外一個坑:
我用 helm 部署 bitnami/redis,部署時設置了 auth.password: "yyyyyyyy"
。我以為密碼就是我設置的字串,結果:
於是我按照 helm 的提示抓 redis 密碼:
原來正確的密碼是 helm 自己產生的, 而不是我設置的。設置成正確的密碼後,砍掉重建 open_im_auth
的 pod,問題就解決了。
順便展示下正確的回傳結構:
{
"errCode": 0,
"errMsg": "",
"data": {
"userID": "transcend",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVSUQiOiJ0cmFuc2NlbmQiLCJQbGF0Zm9ybSI6IiIsImV4cCI6MTk2ODgwNzg1MSwibmJmIjoxNjUzNDQ3ODUxLCJpYXQiOjE2NTM0NDc4NTF9.zt8NhC3zhBsyq6SH35CI16lMKvXGYxqeIClCy4QKmM8",
"expiredTime": 1968807851
}
}
500 GetUserIDFromToken failed
在 call /manager/account_check
檢查用戶是否已經註冊到 OpenIM 時,open_im_api
返回:
{
"errCode": 500,
"errMsg": "GetUserIDFromToken failed"
}
追查 open_im_api
找到以下相關日誌:
乍看之下還以為是我程式中的 http request 沒有帶入 token (或是 token 不完整)。但是檢查了一下程式碼,怎麼看都沒有問題。於是 trace 日誌,調用鏈的起始點是 manage/management_user.go
的第100行,最後在 internal/api/manage/manage_user.go
找到了答案:
原來 OpenIM 在 header 尋找的字段是 token: xxxxxxxxx
,而不是一般我們習慣的 Authorization: Bearer xxxxxx
。
因此, 只需要修改程式碼來配合即可解決:
req, _ = http.NewRequest("POST", _url.String(), bytes.NewReader(data))
// req.Header.Set("Authorization", "Bearer "+*token)
req.Header.Set("token", *token)