一个基于 Electron/Vue3/WindiCss/Go 的聊天APP
- 桌面端: OakenShield(本仓库)
- 服务端: Durin
- 安装文件位于 oakensheild/electron/dist_electron
- 如果想要自己打包
- 安装
Node.js
地址 - 在项目oakenshield文件夹下,下载web所需依赖
npm install
- 开发环境启动
npm run electron:serve
- 使用vite打包web项目
npm run build
- 打包electron
npm run electron:build
- 打包完成的electron文件位于oakenshield/electron/dist_electron
- 安装
- 运行 durin/main.exe
- 如果想要自己打包
var ( Pool *redis.Pool redisServer = flag.String("redisServer", <Your Redis Adress>, "") )
- 在项目目录下运行
go run src/main.go
- 编译
go build src/main.go
- 基于 Electron 开发,可跨平台
- 聊天数据仅在本地存储(除离线消息外)
- 图片使用阿里云OSS存储
- 后端使用redis储存数据
- Go 后端为未来扩展为分布式部署提供便利
使用了websocket进行消息传输,后端使用gorilla/websocket包进行websocket通信
- 前端开启websocket连接
start(id, token) {
if (this.ws == null || this.ws.readyState == 3) {
this.ws = new WebSocket("ws://localhost:23213/message?id=" + id + "&token=" + token);
this.ws.on("open", () => {
console.log("connected");
const heartbeatMessage = {
from: id,
to: "heartbeat",
};
const heartbeatMessageStr = JSON.stringify(heartbeatMessage);
this.heartbeat(heartbeatMessageStr);
});
}
}
- 后端在鉴权后建立并保存websocket连接
wsConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
...
client.SaveClient(id, wsConn)
- 前端发送信息
- /src/component/message
const sendMessage = async () => { ... // 将消息添加到数据库 await db.appendMessage(id.value, message); // 将消息通过ipc发送给主进程 window.api.send("sendMessage", message); ... };
- /electron/main.js
ipcMain.on("sendMessage", (event, arg) => { // 将消息通过websocket发送给服务器 WebSocketWrap.ws.send(JSON.stringify(arg)); // 通过ipc向渲染进程发送"updateModel"事件 mainWindow.webContents.send("updateModel", arg.from); });
- 渲染进程中
window.api.receive("messageSent", message => { // 查询数据库,更新model ... });
- 服务端转发消息
- client通过 ClientManager 的 Send Channel 将接收到的消息发送给 ClientManger
c.manager.Send <- message
- ClientManager 转发消息到对应 client 的 send Channel
manager.Clients[message.To].send <- message
- 收信 client 获取到消息后发送消息
message := <-c.send ... err := c.conn.WriteJSON(message)
- 客户端接受消息
- /electron/main.js
const startWebsocket = (WebSocketWrap, id, token) => { WebSocketWrap.start(id, token); WebSocketWrap.ws.on("open", () => { // 监听服务器发送的消息 WebSocketWrap.ws.on("message", event => { let eventString = event.toString(); let message = JSON.parse(eventString); // 将消息通过ipc发送给渲染进程 mainWindow.webContents.send("appendMessage", { key: message.from, message: message, }); }); }); };
- /src/utils/db.js
window.api.receive("appendMessage", async data => { // 将消息添加到数据库 await db.appendMessage(data.key, data.message); // 向主进程发送updataMessage事件 // 解决数据库操作的异步问题 window.api.send("updateModel", data.key); });
- /electron/main.js
ipcMain.on("updateModel", (event, arg) => { // 将数据库添加操作后的updataModel事件转发给渲染进程 mainWindow.webContents.send("updateModel", arg); });
- 渲染进程中
window.api.receive("updateModel", key => { // 查询数据库,更新model ... });
通过客户端发送心跳包维持websocket连接
- 服务端在建立连接时,为client开启一个heartbeatTimer协程,在心跳包超时时,关闭websocket连接
func (c *Client) heartbeatTimer() { fragmentCount := 600 unregisterTime := 5000 // 检查是否收到了心跳包 for i := 0; i < fragmentCount; i++ { c.mu.Lock() if c.resetHeartbeatTimer { // 如果收到了心跳包(resetHeartbeatTimer=true),重置计时器 c.resetHeartbeatTimer = false i = 0 c.mu.Unlock() continue } c.mu.Unlock() time.Sleep(time.Duration(unregisterTime/fragmentCount) * time.Millisecond) } c.close() } func SaveClient(id string, conn *websocket.Conn) int { ... // 开启心跳包协程 go c.heartbeatTimer() ... }
- 客户端发送心跳包
客户端发送一个收件人为“heartbeat”的消息
// oakenshield/electorn/websocet.js start(id, token) { if (this.ws == null || this.ws.readyState == 3) { ... const heartbeatMessage = { from: id, to: "heartbeat", }; const heartbeatMessageStr = JSON.stringify(heartbeatMessage); this.heartbeat(heartbeatMessageStr); ... } // 当websocket连接建立时,定时发送心跳包 heartbeat(message) { if (this.ws.readyState == 1) { setTimeout(() => { this.ws.send(message); this.heartbeat(message); }, 2000); } }
- 服务端接收心跳包
... if message.To == "heartbeat" { c.resetHeartbeatTimer = true } ...
聊天信息中包括 发件人,收件人,信息类型,信息内容,时间
type Message struct {
From string `json:"from"`
To string `json:"to"`
Content string `json:"content"`
Type string `json:"type"`
Time string `json:"time"`
}
Type
用于判读信息的类型,分为文本(plain)和图片(img),若为图片类型,Content
字段为图片的url
使用了indexedDB+Dexie进行储存
- 创建数据库
this.db.version(1).stores({ contact_list: "id,name", });
- 添加信息
async appendMessage(id, message) { ... //获取要添加的对象 let contactToAppend = await this.db.contact_list.get({ id: id }); ... //更新数据库 return this.db.contact_list.where({ id: id }).modify({ messageList: contactToAppend.messageList, }); }
图片由后端存到阿里云oss,并将图片地址返回给前端
图片的具体储存使用了阿里云提供的实例,并封装至durin/src/util/oss
前端通过 POST
/file
上传图片
func File(c *gin.Context) {
file, err := c.FormFile("upload")
...
// 使用封装好了的方法将图片传到oss
filePath := util.UploadImg(openedFile)
fileDTO := model.FileDTO{
Path: filePath,
}
//将图片接口返回
c.JSON(200, util.NewReturnObject(0, "Success", fileDTO))
}
用户账号中包括 ID,用户名,密码,头像
type Account struct {
Id string `json:"id" redis:"id"`
Name string `json:"name" redis:"name"`
Password string `json:"password" redis:"password"`
Avatar string `json:"avatar" redis:"avatar"`
}
用户数据以Hash的结构保存在redis中,key为id