第87节 OpenIM wasm 任务


❤️💕💕记录sealosopen in new window开源项目的学习过程。k8s,docker和云原生的学习open in new window。Myblog:http://nsddd.topopen in new window


[TOC]

前提知识:

  1. Golang 的基本语法和常用库的使用;
  2. WebAssembly 的基本概念和使用方法;
  3. 索引数据库的基本原理和使用方法。

wasm 学习

一般提到 WebAssembly 技术我们最先想到的是最近比较火的 rust 语言,其实 WebAssembly 是一种二进制的编码格式,其他语言可以通过编译器支持,而写出能够在浏览器前端运行的代码。

💡 介绍:

WebAssembly是一种新的编码方式,可以在现代的网络浏览器中运行 - 它是一种低级的类汇编语言,具有紧凑的二进制格式,可以接近原生的性能运行,并为诸如C / C ++等语言提供一个编译目标,以便它们可以在Web上运行。它也被设计为可以与JavaScript共存,允许两者一起工作。 —— MDN web docs - mozilla.orgopen in new window

我们来谈谈 Go 语言:

Go 语言在 1.11 版本(2018年8月) 加入了对 WebAssembly (Wasm) 的原生支持,使用 Go 语言开发 WebAssembly 相关的应用变得更加地简单。Go 语言的内建支持是 Go 语言进军前端的一个重要的里程碑。在这之前,如果想使用 Go 语言开发前端,需要使用 GopherJSopen in new window,GopherJS 是一个编译器,可以将 Go 语言转换成可以在浏览器中运行的 JavaScript 代码。新版本的 Go 则直接将 Go 代码编译为 wasm 二进制文件,而不再需要转为 JavaScript 代码。更巧的是,实现 GopherJS 和在 Go 语言中内建支持 WebAssembly 的是同一拨人。

最开始,hello world:

第一步,新建文件 main.go,使用 js.Global().get('alert') 获取全局的 alert 对象,通过 Invoke 方法调用。等价于在 js 中调用 window.alert("Hello World")

// main.go
package main

import "syscall/js"

func main() {
	alert := js.Global().Get("alert")
	alert.Invoke("Hello World!")
}

第二步,将 main.go 编译为 static/main.wasm

如果启用了 GO MODULES,则需要使用 go mod init 初始化模块,或设置 GO111MODULE=auto

GOOS=js GOARCH=wasm go build -o static/main.wasm

第三步,拷贝 wasm_exec.js (JavaScript 支持文件,加载 wasm 文件时需要) 到 static 文件夹

第三步,拷贝 wasm_exec.js (JavaScript 支持文件,加载 wasm 文件时需要) 到 static 文件夹

cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" static

第四步,创建 index.html,引用 static/main.wasmstatic/wasm_exec.js

<html>
<script src="static/wasm_exec.js"></script>
<script>
	const go = new Go();
	WebAssembly.instantiateStreaming(fetch("static/main.wasm"), go.importObject)
		.then((result) => go.run(result.instance));
</script>

</html>

第五步,使用 goexec 启动 Web 服务

如果没有安装 goexec,可用 go get -u github.com/shurcooL/goexec 安装,需要将 $GOBIN$GOPATH/bin 加入环境变量

当前的目录结构如下:

# tree
.
├── go.mod
├── index.html
├── main.go
└── static
    ├── main.wasm
    └── wasm_exec.js

浏览器访问 localhost:9999,则会有一个弹出窗口,上面写着 *Hello World!*

为了避免每次编译都需要输入繁琐的命令,可将这个过程写在 Makefile

all: static/main.wasm static/wasm_exec.js
	goexec 'http.ListenAndServe(`:9999`, http.FileServer(http.Dir(`.`)))'

static/wasm_exec.js:
	cp "$(shell go env GOROOT)/misc/wasm/wasm_exec.js" static

static/main.wasm : main.go
	GO111MODULE=auto GOOS=js GOARCH=wasm go build -o static/main.wasm .

这样一个敲一下 make 就够了,代码已经上传到 7days-golang - github.comopen in new window

注册函数(Register Functions)

在 Go 语言中调用 JavaScript 函数是一方面,另一方面,如果仅仅是使用 WebAssembly 替代性能要求高的模块,那么就需要注册函数,以便其他 JavaScript 代码调用。

假设我们需要注册一个计算斐波那契数列的函数,可以这么实现。

// main.go
package main

import "syscall/js"

func fib(i int) int {
	if i == 0 || i == 1 {
		return 1
	}
	return fib(i-1) + fib(i-2)
}

func fibFunc(this js.Value, args []js.Value) interface{} {
	return js.ValueOf(fib(args[0].Int()))
}

func main() {
	done := make(chan int, 0)
	js.Global().Set("fibFunc", js.FuncOf(fibFunc))
	<-done
}
  • fib 是一个普通的 Go 函数,通过递归计算第 i 个斐波那契数,接收一个 int 入参,返回值也是 int。
  • 定义了 fibFunc 函数,为 fib 函数套了一个壳,从 args[0] 获取入参,计算结果用 js.ValueOf 包装,并返回。
  • 使用 js.Global().Set() 方法,将注册函数 fibFunc 到全局,以便在浏览器中能够调用。

js.Value 可以将 Js 的值转换为 Go 的值,比如 args[0].Int(),则是转换为 Go 语言中的整型。js.ValueOf,则用来将 Go 的值,转换为 Js 的值。另外,注册函数的时候,使用 js.FuncOf 将函数转换为 Func 类型(js.FuncOf()方法则将Go语言中的fibFunc函数转换为JavaScript中的函数对象,以便在JavaScript中调用。),只有 Func 类型的函数,才能在 JavaScript 中调用。可以认为这是 Go 与 JavaScript 之间的 接口/约定

js.Func() 接受一个函数类型作为其参数,该函数的定义必须是:

func(this Value, args []Value) interface{}
// this 即 JavaScript 中的 this
// args 是在 JavaScript 中调用该函数的参数列表。
// 返回值需用 js.ValueOf 映射成 JavaScript 的值

在 main 函数中,创建了信道(chan) done,阻塞主协程(goroutine)。fibFunc 如果在 JavaScript 中被调用,会开启一个新的子协程执行。

💡 注意这个管道,因为最开始设置的是 0,所以是一个非缓冲的管道,非缓冲的管道只能在发送和接收操作都准备好时才能进行通信。通过<-done语句,主goroutine会一直阻塞,直到从done通道中接收到一个值为止。这个语句的作用是等待其他goroutine的执行完成,以保证程序不会在主goroutine退出之前终止。

接下来,修改之前的 index.html,在其中添加一个输入框(num),一个按钮(btn) 和一个文本框(ans,用来显示计算结果),并给按钮添加了一个点击事件,调用 fibFunc,并将计算结果显示在文本框(ans)中。

<html>
...
<body>
	<input id="num" type="number" />
	<button id="btn" onclick="ans.innerHTML=fibFunc(num.value * 1)">Click</button>
	<p id="ans">1</p>
</body>
</html>

操作 DOM

在上一个例子中,仅仅是注册了全局函数 fibFunc,事件注册,调用,对 DOM 元素的操作都是在 HTML 中通过原生的 JavaScript 函数实现的。这些事情,能不能全部在 Go 语言中完成呢?答案可以。

首先修改 index.html,删除事件注册部分和 对 DOM 元素的操作部分。

<html>
...
<body>
	<input id="num" type="number" />
	<button id="btn">Click</button>
	<p id="ans">1</p>
</body>
</html>

修改 main.go:

package main

import (
	"strconv"
	"syscall/js"
)

func fib(i int) int {
	if i == 0 || i == 1 {
		return 1
	}
	return fib(i-1) + fib(i-2)
}

var (
	document = js.Global().Get("document")
	numEle   = document.Call("getElementById", "num")
	ansEle   = document.Call("getElementById", "ans")
	btnEle   = js.Global().Get("btn")
)

func fibFunc(this js.Value, args []js.Value) interface{} {
	v := numEle.Get("value")
	if num, err := strconv.Atoi(v.String()); err == nil {
		ansEle.Set("innerHTML", js.ValueOf(fib(num)))
	}
	return nil
}

func main() {
	done := make(chan int, 0)
	btnEle.Call("addEventListener", "click", js.FuncOf(fibFunc))
	<-done
}
  • 通过 js.Global().Get("btn")document.Call("getElementById", "num") 两种方式获取到 DOM 元素。
  • btnEle 调用 addEventListener 为 btn 绑定点击事件 fibFunc。
  • 在 fibFunc 中使用 numEle.Get("value") 获取到 numEle 的值(字符串),转为整型并调用 fib 计算出结果。
  • ansEle 调用 Set("innerHTML", ...) 渲染计算结果。

重新编译 main.go,访问 localhost:9999,效果与之前是一致的。

回调函数(Callback Functions)

在 JavaScript 中,异步+回调是非常常见的,比如请求一个 Restful API,注册一个回调函数,待数据获取到,再执行回调函数的逻辑,这个期间程序可以继续做其他的事情。Go 语言可以通过协程实现异步。

假设 fib 的计算非常耗时,那么可以启动注册一个回调函数,待 fib 计算完成后,再把计算结果显示出来。

我们先修改 main.go,使得 fibFunc 支持传入回调函数。

package main

import (
	"syscall/js"
	"time"
)

func fib(i int) int {
	if i == 0 || i == 1 {
		return 1
	}
	return fib(i-1) + fib(i-2)
}

func fibFunc(this js.Value, args []js.Value) interface{} {
	callback := args[len(args)-1]
	go func() {
		time.Sleep(3 * time.Second)
		v := fib(args[0].Int())
		callback.Invoke(v)
	}()

	js.Global().Get("ans").Set("innerHTML", "Waiting 3s...")
	return nil
}

func main() {
	done := make(chan int, 0)
	js.Global().Set("fibFunc", js.FuncOf(fibFunc))
	<-done
}
  • 假设调用 fibFunc 时,回调函数作为最后一个参数,那么通过 args[len(args)-1] 便可以获取到该函数。这与其他类型参数的传递并无区别。
  • 使用 go func() 启动子协程,调用 fib 计算结果,计算结束后,调用回调函数 callback,并将计算结果传递给回调函数,使用 time.Sleep() 模拟 3s 的耗时操作。
  • 计算结果出来前,先在界面上显示 Waiting 3s...

接下来我们修改 index.html,为按钮添加点击事件,调用 fibFunc

<html>
...
<body>
	<input id="num" type="number" />
	<button id="btn" onclick="fibFunc(num.value * 1, (v)=> ans.innerHTML=v)">Click</button>
	<p id="ans"></p>
</body>
</html>
  • 为 btn 注册了点击事件,第一个参数是待计算的数字,从 num 输入框获取。
  • 第二个参数是一个回调函数,将参数 v 显示在 ans 文本框中。

接下来,重新编译 main.go,访问 localhost:9999,随便输入一个数字,点击 Click。页面会先显示 Waiting 3s...,3s过后显示计算结果。

工具框架

Gorm 学习教程

结构体标记(tags)

使用结构体声明模型时,标记(tags)是可选项。gorm支持以下标记:

支持的结构体标记(Struct tags)

结构体标记(Tag)描述
Column指定列名
Type指定列数据类型
Size指定列大小, 默认值255
PRIMARY_KEY将列指定为主键
UNIQUE将列指定为唯一
DEFAULT指定列默认值
PRECISION指定列精度
NOT NULL将列指定为非 NULL
AUTO_INCREMENT指定列是否为自增类型
INDEX创建具有或不带名称的索引, 如果多个索引同名则创建复合索引
UNIQUE_INDEX和 INDEX 类似,只不过创建的是唯一索引
EMBEDDED将结构设置为嵌入
EMBEDDED_PREFIX设置嵌入结构的前缀
-忽略此字段

关联相关标记(tags)

结构体标记(Tag)描述
MANY2MANY指定连接表
FOREIGNKEY设置外键
ASSOCIATION_FOREIGNKEY设置关联外键
POLYMORPHIC指定多态类型
POLYMORPHIC_VALUE指定多态值
JOINTABLE_FOREIGNKEY指定连接表的外键
ASSOCIATION_JOINTABLE_FOREIGNKEY指定连接表的关联外键
SAVE_ASSOCIATIONS是否自动完成 save 的相关操作
ASSOCIATION_AUTOUPDATE是否自动完成 update 的相关操作
ASSOCIATION_AUTOCREATE是否自动完成 create 的相关操作
ASSOCIATION_SAVE_REFERENCE是否自动完成引用的 save 的相关操作
PRELOAD是否自动完成预加载的相关操作

OpenIM 客服端的 Wasm 设计

root@PS2023EVRHNCXG:~/workspaces/openim/openim-sdk-core/wasm# tree
.
├── cmd
│   ├── Makefile                    # 构建 wasm 包的 Makefile 文件
│   └── main.go                     # wasm 包的入口文件
├── event_listener
│   ├── callback_writer.go          # 回调函数写入器,用于将事件监听器的回调函数写入到 wasm 包中
│   ├── caller.go                   # 调用器,用于调用 wasm 包中的函数
│   └── listener.go                 # 事件监听器,用于监听 wasm 包中的事件
├── indexdb
│   ├── black_model.go              # 黑名单模型,用于定义黑名单数据表结构
│   ├── cache_message.go            # 缓存消息模型,用于定义缓存消息数据表结构
│   ├── chat_log_model.go           # 聊天记录模型,用于定义聊天记录数据表结构
│   ├── chat_log_reaction_extension_model.go  # 聊天记录反应扩展模型,用于定义聊天记录反应扩展数据表结构
│   ├── conversation_model.go       # 会话模型,用于定义会话数据表结构
│   ├── conversation_unread_message_model.go  # 未读消息模型,用于定义未读消息数据表结构
│   ├── friend_model.go             # 好友模型,用于定义好友数据表结构
│   ├── friend_request_model.go     # 好友请求模型,用于定义好友请求数据表结构
│   ├── group_member_model.go       # 群成员模型,用于定义群成员数据表结构
│   ├── group_model.go              # 群组模型,用于定义群组数据表结构
│   ├── group_request.model.go      # 群组请求模型,用于定义群组请求数据表结构
│   ├── indexdb.go                  # 索引数据库,用于定义索引数据库的接口和实现
│   ├── notification_model.go       # 通知模型,用于定义通知数据表结构
│   ├── super_group_chat_log_model.go  # 超级群聊天记录模型,用于定义超级群聊天记录数据表结构
│   ├── super_group_model.go        # 超级群模型,用于定义超级群数据表结构
│   ├── temp_struct                 # 临时结构体文件夹,用于存放一些临时的结构体定义
│   │   └── struct.go
│   └── user_model.go               # 用户模型,用于定义用户数据表结构
└── wasm_wrapper
    ├── wasm_conversation_msg.go    # wasm 包中的会话消息相关函数
    ├── wasm_friend.go              # wasm 包中的好友相关函数
    ├── wasm_group.go               # wasm 包中的群组相关函数
    ├── wasm_init_login.go          # wasm 包中的初始化和登录相关函数
    ├── wasm_signaling.go           # wasm 包中的信令相关函数
    ├── wasm_third.go               # wasm 包中的第三方相关函数
    └── wasm_user.go                # wasm 包中的用户相关函数

wasm 中负责的模块:

  • wasm/indexdb/chat_log_model.go
  • wasm/indexdb/black_model.go
  • wasm/indexdb/conversation_model.go

测试模块的代码:

testv2/
├── callback.go // 回调函数
├── config.go // 配置文件
├── conversation_test.go // 对话测试
├── create_msg_test.go // 创建消息测试
├── empty_test.go // 空测试
├── file_test.go // 文件测试
├── friend_test.go // 好友测试
├── group_test.go // 群组测试
├── init.go // 初始化
├── listener.go // 监听器
├── signaling_test.go // 信令测试
├── sync_test.go // 同步测试
├── user_test.go // 用户测试
└── work_moment_test.go // 工作时刻测试

chat_log_model

获取到 messge:

func (i *LocalChatLogs) GetMessage(ctx context.Context, conversationID, clientMsgID string) (*model_struct.LocalChatLog, error) {
	msg, err := Exec(conversationID, clientMsgID)
	if err != nil {
		return nil, err
	} else {
		if v, ok := msg.(string); ok {
			result := model_struct.LocalChatLog{}
			err := utils.JsonStringToStruct(v, &result)
			if err != nil {
				return nil, err
			}
			return &result, err
		} else {
			return nil, ErrType
		}
	}
}

对应的 db message:

func (d *DataBase) GetMessage(ctx context.Context, conversationID string, clientMsgID string) (*model_struct.LocalChatLog, error) {
	d.initChatLog(ctx, conversationID)
	var c model_struct.LocalChatLog
	return &c, utils.Wrap(d.conn.WithContext(ctx).Table(utils.GetTableName(conversationID)).Where("client_msg_id = ?",
		clientMsgID).Take(&c).Error, "GetMessage failed")
}
  • getMessage
输入参数类型说明备注
clientMsgIDstring消息ID
返回参数类型说明备注
errCodenumber自定义即可,0成功,非0失败如果获取不到消息也需要返回错误
errMsgstring详细的err信息
datastringLocalChatLog(消息表对象数据)对象转换成string

参考sql语句说明:

SELECT * FROM `local_chat_logs` WHERE client_msg_id = "063031b86f8e503c6038efb2b835f216" LIMIT 1

chat_log_model

wasm 模块获取到 messgae 实现

func (i *LocalChatLogs) GetMessage(ctx context.Context, conversationID, clientMsgID string) (*model_struct.LocalChatLog, error) {
    msg, err := Exec(conversationID, clientMsgID)
    if err != nil {
        return nil, err
    } else {
        if v, ok := msg.(string); ok {
            result := model_struct.LocalChatLog{}
            err := utils.JsonStringToStruct(v, &result)
            if err != nil {
                return nil, err
            }
            return &result, err
        } else {
            return nil, ErrType
        }
    }
}

对应的 db 模块 message实现:

func (d *DataBase) GetMessage(ctx context.Context, conversationID string, clientMsgID string) (*model_struct.LocalChatLog, error) {
    d.initChatLog(ctx, conversationID)
    var c model_struct.LocalChatLog
    return &c, utils.Wrap(d.conn.WithContext(ctx).Table(utils.GetTableName(conversationID)).Where("client_msg_id = ?",
        clientMsgID).Take(&c).Error, "GetMessage failed")
}

文档的实现(给前端同学看的,只需要修改 data 的备注,是对象转化为什么类型)

  • getMessage
输入参数类型说明备注
clientMsgIDstring消息ID
返回参数类型说明备注
errCodenumber自定义即可,0成功,非0失败如果获取不到消息也需要返回错误
errMsgstring详细的err信息
datastringLocalChatLog(消息表对象数据)对象转换成string

参考sql语句说明:

SELECT * FROM `local_chat_logs` WHERE client_msg_id = "063031b86f8e503c6038efb2b835f216" LIMIT 1

wasm 的getMessageList 的实现

func (i *LocalChatLogs) GetMessageList(ctx context.Context, conversationID string, count int, startTime int64, isReverse bool) (result []*model_struct.LocalChatLog, err error) {
    msgList, err := Exec(conversationID, count, startTime, isReverse, i.loginUserID)
    if err != nil {
        return nil, err
    } else {
        if v, ok := msgList.(string); ok {
            var temp []model_struct.LocalChatLog
            err := utils.JsonStringToStruct(v, &temp)
            if err != nil {
                return nil, err
            }
            for _, v := range temp {
                v1 := v
                result = append(result, &v1)
            }
            return result, err
        } else {
            return nil, ErrType
        }
    }
}

对应的 db 模块 message实现

func (d *DataBase) GetMessageList(ctx context.Context, conversationID string, count int, startTime int64, isReverse bool) (result []*model_struct.LocalChatLog, err error) {
    d.mRWMutex.Lock()
    defer d.mRWMutex.Unlock()
    var condition, timeOrder, timeSymbol string
    if isReverse {
        timeOrder = "send_time ASC"
        timeSymbol = ">"
    } else {
        timeOrder = "send_time DESC"
        timeSymbol = "<"
    }
    condition = "send_time " + timeSymbol + " ?"

    err = utils.Wrap(d.conn.WithContext(ctx).Table(utils.GetTableName(conversationID)).Where(condition, startTime).
        Order(timeOrder).Offset(0).Limit(count).Find(&result).Error, "GetMessageList failed")
    if err != nil {
        return nil, err
    }
    return result, err
}

文档的实现(给前端同学看的,只需要修改 data 的备注,是对象转化为什么类型)

  • getMessageList
输入参数类型说明备注
sourceIDstring关于某人的ID也可能是写扩散模式下群ID
sessionTypenumber会话类型,单聊1、读扩散群2、大群为3
countnumber获取消息的数量
startTimenumber消息发送时间,毫秒
isReverseboolean消息为正向拉取还是反向拉取默认情况为false,即为正向拉取(从新消息到老消息),order by 后面的排序规则为send_time DESC 降序排列,send_time为 <;当为true的情况,即为反向拉取,order by 后面的排序规则为send_time ASC 升序排列,send_time为 >
loginUserIDstring用户登录ID需要根据会话的类型和sourceID判断,当sessionType为1并且sourceID为登录者ID时候,搜索sql为 AND
返回参数类型说明备注
errCodenumber自定义即可,0成功,非0失败获取不到的时候返回空数组不需要返回错误
errMsgstring详细的err信息
datastring[]LocalChatLog(消息表对象数组数据)对象转换成string

参考sql语句说明:

-- 1、sessionType == 1 && sourceID == d.loginUserID
SELECT * FROM `local_chat_logs` WHERE send_id = "812146266" And  recv_id = "812146266" AND status <=1 And session_type = 3 And send_time < 1664357584025 ORDER BY send_time DESC LIMIT 30;
-- 注:其中status固定为3
-- 2、其他场景
SELECT * FROM `local_chat_logs` WHERE send_id = "812146266" OR  recv_id = "812146266" AND status <=1 And session_type = 3 And send_time < 1664357584025 ORDER BY send_time DESC LIMIT 30;

END 链接