OpenIM 整合(踩坑)筆記 01

OpenIM 號稱是由一群前微信等技術專家一起出來打造的開源 IM 系統。撇開政治不談,個人覺得微信在 IM 領域確實是佼佼者,因此決定整合 OpenIM 到現有的產品中,以減少浪費時間重複造輪子。

本篇目標是串連原有的帳號系統與 OpenIM 的帳號系統,完成 自動註冊用戶到 OpenIM (下圖中的第 3 ~ 4 步驟)。

Open-IM用户注册

文中:

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:
  dbMysqlUserName: root #不能修改
  dbMysqlPassword: ewjriwejirwe #root 密碼

MySQL 帳密必須是 root 帳密,否則一開始就無法運行。我猜測是建表的問題,但即使 grant all privileges,依然無法解決。最後還是必須改回去 root。

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
}

註冊用戶

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:

  1. 操作管理員用戶 (例如獲取 manager token), 但是 MySQL 裡面沒有 admin 的帳號
  2. 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 有錯誤訊息:

Imgur

原本以為是 OpenIM 寫死密碼/不支援密碼,後來才發現 configmap 裡設置的 redis 密碼不是正確的密碼。至於為什麼會有這個問題,這就是另外一個坑:

我用 helm 部署 bitnami/redis,部署時設置了 auth.password: "yyyyyyyy"。我以為密碼就是我設置的字串,結果:

Imgur

於是我按照 helm 的提示抓 redis 密碼:

Imgur

原來正確的密碼是 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 找到以下相關日誌:

Imgur

乍看之下還以為是我程式中的 http request 沒有帶入 token (或是 token 不完整)。但是檢查了一下程式碼,怎麼看都沒有問題。於是 trace 日誌,調用鏈的起始點是 manage/management_user.go 的第100行,最後在 internal/api/manage/manage_user.go 找到了答案:

Imgur

原來 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)