TRPG Engine —— 一个功能完善即时通讯解决方案

项目背景

除了传统的聊天软件,还有为固定需求打造的定位其他的聊天软件

比如钉钉立足于工作流,slack专注于程序员之间的项目沟通。而TRPG Engine就是一款为了小众的跑团玩家所打造的通用即时通讯解决方案

项目亮点

  • 基于XML描述的人物卡系统(可以理解为动态表单) - Playground
  • Web端与RN端共享redux状态的实践与工具链
  • 多端并存与代码共享(Web端, RN端, Portal端 (Portal端是RN端通过webview进行一部分中间操作的方式,类似于各种手机App的H5端) )
  • 其他的一些自研实用工具,如RN端WEB端通用Portal组件, 快速生成通用表单, 基于BBCode的消息解释器, 通用缓存管理机制
  • 工程化代码,可拓展性强
  • 移动端兼容与PWA

依赖

  • MySql 5.7+
  • Redis

功能列表

通用功能

  • 用户登入登出
  • 用户注册
  • 私聊/群聊
  • 头像上传与裁剪
  • 用户设置
  • 好友管理
    • 好友发送邀请/同意邀请
  • 基于BBCode的消息解析器
    • url内容
    • 图片内容
    • @提及
  • 多种消息类型
    • 通用消息
    • 提示消息
    • 卡片消息
  • 消息回复与消息撤回
  • 自动抓取消息内的网址的预览信息
  • 基于slate的富文本编辑器
  • 消息通知
    • 移动端基于upush。包括本地进程未被杀死的本地推送与本地被杀死后的upush推送
  • 多种文件管理策略
    • 头像上传七牛云或本地
    • 聊天图片使用外置图片服务转发到第三方图床
    • 聊天文件存储在本地,定时删除
  • 单向聊天消息机器人
  • 群组多面板
    • 多面板类型: 目前有笔记面板与文字频道
    • 面板的编辑/删除/拖拽排序
  • app热更新与apk更新
    • 热更新基于自部署的codepush服务器, apk更新会自动获取最新的apk版本
  • app下载管理
  • 多国语言(中英, 尚未完全覆盖)

跑团相关

  • 基于Slate的笔记系统
  • 人物卡系统
    • 基于XML的布局描述与内置JS沙盒解释器来解释js脚本
    • 人物卡的切换与切换时发送消息变换头像与名字
    • 人物卡分享与Fork
  • 投骰表达式与消息拦截器
  • 输入时向所有人发送输入状态
  • 在线招募系统

线上监控

  • 计划任务记录
  • 接口耗时统计
  • 请求限流
  • 系统日志: 日志会被转发到loggly或本地记录。其他的操作相关会存储到数据库
    • 用户登录记录
    • 机器人记录
    • 投骰记录
    • oss文件记录
  • 登录/注册统计汇总
  • 前端后端错误汇报

项目规模

  • 开发时间: 3年
  • 所用数据表: 61张
  • 功能完整的多端:
    • 两版网页端
    • 基于React Native的安卓端

预览

在线地址: https://trpg.moonrailgun.com/
开源地址: https://github.com/TRPGEngine/Client

又拍云整站网页存放到OSS的解决方案

背景

前后端分离后允许前端页面静态访问,因此整站放到OSS上成为可能

但是有一个问题就是要如何实现原来单页应用上刷新页面无法获取到正确的网页文件的问题

该问题在nginx上的解决方案是这样的:

1
2
3
location / {
try_files $uri /index.html;
}

解决方案

又拍云边缘规则编程模式下配置规则

1
$WHEN($NOT($MATCH($_URI,'[\\.]')))/index.html

表示所有连接都返回根目录的index.html文件

更加复杂的场景

对于实际场景中,可能有多个应用并存,比如我的TRPG Engine中就有多个单页应用存在一个域名下

三个应用:

  • /index.html 主应用
  • /playground/index.html playground页,只有一个页面
  • /portal/index.html portal页, 会有多个页面存在

我给出配置如下:

优先级1: playground break

1
$WHEN($EQ($_URI,'/playground'))/playground/index.html

优先级2: portal break

1
$WHEN($ALL($MATCH($_URI,'^/portal'),$NOT($MATCH($_URI,'[\\.]'))))/portal/index.html

优先级3: 单页应用

1
$WHEN($NOT($MATCH($_URI,'[\\.]')))/index.html

其中break的意思是满足一个条件则不会继续往下匹配

参考链接

参考链接: https://cnodejs.org/topic/5badd93037a6965f59051d40

记一次重启网络服务后网络不通的调试

背景

我的网络服务端是用docker搭建的Nginx服务器。在一次重启网络服务service network restart后监控机器人告知服务挂了。检查以后发现服务可以ping通,查询日志无异常,docker服务反复重启也无法使其正常生效。考虑到使用的是弹性公网会不会是虚拟网络之间的问题,经排查后也不存在该问题。

解决方案

在google找方案的时候受到启发,输入netstat -tunlp检查监听端口。果然没有只有一条tcp6 :::80的监听而没有应当有的0.0.0.0:80的监听。那么问题就出在端口方面,而我docker的状态明明写的是0.0.0.0:80->80/tcp啊,为什么会没有监听ipv4的端口?

为使其能够正常运行。编辑/etc/sysctl.conf加上一条以开启ipv4转发

1
net.ipv4.ip_forward=1

如果改行已存在则直接修改.

成功后输入sysctl -p检查变更已成功应用。

无需重启即可生效,检查后发现网络服务已恢复正常

让Chrome显示完整网址

Chrome 76+版本以后默认隐藏 https:// , www无关紧要的标识符

作为开发者,这些信息是非常有价值的。因此需要手动再将其重新开启

在flags页面中将其重新开启

地址栏chrome://flags, 进入flags页面

Chrome 版本小于83

Omnibox UI Hide Steay-State URL Scheme and Trivial Subdomains设置为禁用

Chrome 版本大于等于83

Context menu show full URLs设置为启用, 并右键地址栏选中总是显示完整网址

adb devices无法获取夜神模拟器的解决方案

在开启模拟器的前提下。在终端输入

1
$ adb devices

如果没有看到夜神模拟器的设备。则将${ANDROID_HOME}/platform-tools/adb.exe复制并覆盖夜神模拟器根目录下的adb.exenox_adb.exe

重启后查看效果(重启前确保后台的adb.exenox_adb.exe进程已被关闭)

新服务器必做二三事

主要是给自己一个提醒。一个新服务器需要做哪些必备项目

增加新用户用于常用用户

1
2
$ useradd xxxx
$ passwd xxx

切换到用户 设置为秘钥登录

1
$ su xxx
1
2
3
4
5
$ cd ~
$ mkdir .ssh
$ chmod 700 .ssh
$ vim authorized_keys # 插入登录公钥
$ chmod 600 authorized_keys

关闭密码登录和root用户登录 仅允许密码登录

1
$ exit # 退出到root用户
1
2
$ vim /etc/ssh/sshd_config
$ service sshd restart

设定以下参数:

1
2
PermitRootLogin no # 不允许root用户直接登录
PasswordAuthentication no # 不允许通过密码登录(即仅允许秘钥登录)

可选项目

修改主机名

1
2
$ vim /etc/hostname
$ hostname $(cat /etc/hostname)

快速构建软件文档——docusaurus v2

背景

来源是想要给我的应用做一个首页。需要有文档、首页两项。如果文档中能插入一些比较复杂的内容(如在线预览)这样的功能的话就更好了

开始使用的是docusaurus v1, 后来发现他无法在markdown文档中插入iframe。 会触发一个诡异的bug,导致渲染中断,无法正常渲染后面的内容,因此就中断了。后来发现docusaurus开始开发了v2版。虽然处于Beta阶段但还是能进行初步的使用了。

环境

  • docusaurus v2.0.0-alpha.50

特性

  • 使用React开发。可以体会到现代语言的优势。
  • 内置文档、博客,集成Algolia DocSearch。开箱即用。
  • 完全可定制的组件和样式, 增加完全的可定制化能力
  • MDX实现。在Markdown中也能使用React组件

自定义独立页面

略过初始化内容。我们直接进入自定义的阶段。

docusaurus 的独立页面在src/pages文件夹中, 就和写React组件一样我们可以利用React很轻松的构建属于自己的页面, 首页就是index.js。

对于非模板化的页面。比如首页、showcase、问卷调查这些,都可以通过独立页面来实现。

自定义渲染组件

docusaurus 提供了很强的自定义能力,使用@theme/*引用组件。提供一套预设的组件用于渲染网站的各个部位。其内容在https://github.com/facebook/docusaurus/tree/master/packages/docusaurus-theme-classic

对于该项目中的每一个组件,我们都能进行定制。使用docusaurus swizzle <theme name> [component name]命令将样式包中的组件复制到自己的src/theme文件夹中。可以指定的替换相应组件的实现

如:

1
npm run swizzle @docusaurus/theme-classic Footer

来重写页脚。

@theme/* 引用会以此查找本地的src/theme文件夹的组件,主题包里的组件。

自定义markdown渲染

Markdown是我们写文档重要的工具,有时候Markdown基本的语法无法满足我们的需要,此时就可以自己编写渲染逻辑。这样在不丧失Markdown简洁的前提下给予我们文档更加强大的表现力

比如我就自己写了一个组件, 用于将代码与预览相互转化: https://github.com/TRPGEngine/Server/blob/master/services/Website/website/src/plugins/remark-template-previewer.js

当然还有一种方法是利用MDX的特性在Markdown中引用React组件

成果

更多docusaurus的特性请查阅官方文档

wsl2入坑指南

背景

WSL2支持了docker环境,这给了我一个入坑wsl的理由。在一段时间的配置和踩坑以后我将wsl配置需求记录下来

任务目标:

  • 配置wsl2
  • 在wsl安装docker环境
  • 搭建基本环境

安装

更新Windows10的版本以安装wsl2

WSL 2 仅适用于 Windows 10 版本 18917 或更高版本

因此首先我们要window10版本升级到匹配的版本。目前来说正式发行版无法升级到相应版本,因此需要启用开发者预览版。启用方式是搜索insider打开Windows 预览体验计划的设置页面(在设置的 更新和安全 的最后一项)

根据提示将自己的微软账号注册为开发者账号。在获取预览版本的频率中选择 慢(Slow) 因为我们需要一个相对稳定的版本。

然后手动检测windows更新即可升级到符合条件的版本

PS: 注意, 更新版本以后可能会丢失一些windows的系统设置。需要手动检查并重新设置回来

启用wsl2

管理员身份打开Powershell

首先启用wsl

1
Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux

重启计算机

以管理员身份打开Powershell

1
2
dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart
dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart

重启计算机

Microsoft Store搜索wsl下载自己喜欢的Linux系统

注意!!!!
如果你使用wsl2的目的是为了docker。请务必选择Ubuntu 18.04系统! 因为Debian无法正常启动docker服务(其他系统没有测试过)!

在Powershell中查看自己当前已安装的Linux系统与使用的版本

1
2
3
wsl -l -v
# 或
wsl --list --verbose

设置linux系统使用的wsl版本。wsl1和wsl2据开发者所说会一直共存下去。因此要手动分配

1
wsl --set-version <Distro> 2

<Distro> 替换为上面列出的系统名称

将wsl2设置为默认的wsl体系

1
wsl --set-default-version 2

这会使你安装的任何新发行版均初始化为 WSL 2 发行版。

将软件源更换为清华源(可选)

  • 清华源
    1
    2
    sudo cp /etc/apt/sources.list /etc/apt/sources.list.bak
    sudo vim /etc/apt/sources.list

将软件源设置为国内源的话操作会更加流畅

安装Docker

进入wsl, 输入uname -a可以检测版本

快速安装Docker

1
2
3
4
$ curl -fsSL https://get.docker.com -o get-docker.sh
$ sudo sh get-docker.sh
$ sudo service docker start
$ sudo usermod -aG docker $USER

使用service docker status查看docker服务的状态

安装zsh作为常用的shell

1
2
3
4
5
# 安装zsh
sudo apt-get install zsh

# 安装oh-my-zsh
sh -c "$(wget https://raw.github.com/robbyrussell/oh-my-zsh/master/tools/install.sh -O -)"

相互访问网络应用

WSL 2 做了架构的巨大变更,使用了虚拟化技术,并仍在努力改进网络支持。由于 WSL 2 现在运行在虚拟机中,因此你从 Windows 访问 Linux 网络应用程序需要使用该 VM 的 IP 地址,反之亦然,你需要 Windows 主机的 IP 地址才能从 Linux 中访问 Windows 网络应用程序。 WSL 2 的目标是尽可能使用 localhost 访问网络应用程序!可以在文档中找到有关如何执行此操作的完整详细信息和步骤。

参考资料

微软: WSL 2 的安装说明
知乎: WSL 2中安装Docker

浅谈节流(throttle)与防抖动(debounce)

简介

节流与防抖是性能优化常见的方式,用于限制一些高频操作导致对cpu无意义的负担。
我搜索了网络上很多相关的文章,都没有办法直观表现出两者的区别。因此我自己总结了一下两者的区别,并用几个例子来解释一下两者区别

一下内容均以 lodash 的实现为例

防抖动debounce

防抖动的核心概念是当停止调用函数一段时间后。调用该函数

example:

1
2
3
4
5
6
const d = _.debounce(() => console.log('call func'), 1000, {leading: true, trailing: true});

const dt = setInterval(() => {
console.log('call timer');
d();
}, 200);

输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
call func # 如果 leading 为true
call timer
call timer
call timer
call timer
call timer
...
call timer
call timer
call timer
(执行clearInterval(dt))
(等待1s后)
call func # 如果 trailing 为true

即如果在限定时间(本例为1s)内不断调用防抖函数,则永远不会触发1s后的trailing的输出

节流throttle

节流的核心概念是一段时间内,函数最多只能被调用一次。

example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const t1 = _.throttle(() => console.log('call func'), 1000, {leading: true, trailing: true});
t1(); // 立即输出call func

const t2 = _.throttle(() => console.log('call func'), 1000, {leading: false, trailing: true});
t2(); // 一秒后输出call func

const t3 = _.throttle(() => console.log('call func'), 1000, {leading: false, trailing: false});
t3(); // 无反应

const t4 = _.throttle(() => console.log('call func'), 1000, {leading: true, trailing: true});
const t4t = setInterval(t4, 200); // 立即输出call func, 之后每秒输出一次

const t5 = _.throttle(() => console.log('call func'), 1000, {leading: false, trailing: true});
const t5t = setInterval(t5, 200); // 一秒后输出call func, 之后每秒输出一次

const t6 = _.throttle(() => console.log('call func'), 1000, {leading: false, trailing: false});
const t6t = setInterval(t6, 200); // 一秒后输出call func, 之后每秒输出一次 因为内部是通过 debounce 的 maxWait来实现的

如果在 throttle 规定的时间内有多次触发的话。必定会在时间段结束时触发函数调用
如果只有一次触发的话。如果 leadingtrailing 都为 true 。只会触发开始的那次函数调用

共同点

对于lodash来说
throttle的实现是通过debounce来实现的

lodash 实现

1
2
3
4
5
6
7
8
9
throttle(func, wait, options);

// 等价于

debounce(func, wait, {
options.leading || true,
options.trailing || true,
'maxWait': wait
});

参考文章

https://css-tricks.com/debouncing-throttling-explained-examples/

Docker Swarm 模型配合 traefik 实现集群sticky实践

背景

为什么要用Docker Swarm, 相比 Kubernetes 有什么好处

Docker Swarm可以看作是docker自带的一个简化版的Kubernetes, 拥有Kubernetes的基本功能如:

  • 更新
  • 回滚
  • 动态扩容
  • 分布式部署

Docker Swarm 的优势在于:

  • Docker 自带, 无需另外安装。学习成本低
  • 单机也能很好的使用,也可以很方便的进行实例扩容与设备扩容
  • Swarm 只有2层网络封装,而 Kubernetes 有5层网络封装
  • Swarm 本身占用的内存只有100M左右,而 Kubernetes 的简化版 k3s 也需要512M的内存空间,对小资源机器友好

Traefik 是什么, 为什么要使用它

Traefik 是一个反向代理软件,类似 Nginx但对于微服务有很好的优化。可以搭配各种分布式发现服务而无需另外配置。在本篇文章中我们会使用 Docker Provider 作为服务发现。

虽然 Docker Swarm 自带了http请求的分发,但是无法实现 sticky 功能(即同一用户的请求会分发到同一后端实例),因此需要Traefik 作为请求的中间件来分发请求

搭建 Docker Swarm 环境

Docker Swarm 环境非常好搭建,因为已经集成到Docker中了。我们安装好Docker以后就可以直接使用了

依赖:

  • Docker 1.12+
1
2
# 初始化swarm, 并将当前节点作为manager
$ docker swarm init
1
2
3
# 或者使用--advertise-addr 和 --listen-addr参数来指定使用哪个IP作为沟通IP
# 两个参数一般保持一致即可
$ docker swarm init --advertise-addr 10.0.0.1:2377 --listen-addr 10.0.0.1:2377

使用Docker快速搭建Traefik

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
version: '3'

services:
reverse-proxy:
# The official v2.0 Traefik docker image
image: traefik:v2.0
# Enables the web UI and tells Traefik to listen to docker
command: --api.insecure=true --providers.docker.endpoint=tcp://127.0.0.1:2377 --providers.docker.swarmMode=true
ports:
# The HTTP port
- "80:80"
# The Web UI (enabled by --api.insecure=true)
- "8080:8080"
volumes:
# So that Traefik can listen to the Docker events
- /var/run/docker.sock:/var/run/docker.sock
  • api.insecure参数用于打开WEB UI. 可以访问127.0.0.1:8080/api/rawdata获取当前可以连接到的服务的相关信息
  • providers.docker.endpoint指向docker的沟通端口。默认端口为2377
  • providers.docker.swarmMode 表示为swarm模式

创建测试服务

此处使用 whoami 镜像提供的HTTP服务用于打印出集群连接相关信息

将以下文件保存为docker-compose.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
version: '3'

services:
whoami:
# A container that exposes an API to show its IP address
image: containous/whoami
deploy:
replicas: 3
labels:
- "traefik.http.routers.whoami.rule=Host(`whoami.docker.localhost`)"
- "traefik.http.services.whoami.loadbalancer.server.port=80"
- "traefik.http.services.whoami.loadbalancer.sticky=true"
- "traefik.http.services.whoami.loadbalancer.sticky.cookie.name=foosession"

启动集群:

1
docker stack deploy -c ./docker-compose.yml whoami

测试命令:

1
$ curl -vs -c cookie.txt -b cookie.txt -H "Host: whoami.docker.localhost" http://127.0.0.1

注意需要带上 header Host 这样才能成功反向代理