如何开发与坚持维护一个企业级的开源项目

TRPG Engine 是我一直坚持维护的一个开源项目。正如所有的开源项目都起源于开发者的兴趣,TRPG Engine也是我对于跑团这一小众领域的兴趣。

我在没有支援没有赞助,单凭个人爱好独立坚持开发3年以上不间断。前端代码累计提交2700+, 后端代码累计提交1200+,风雨无阻。

谨以此文,分享一下我维护这个开源项目的经历。并与千千万万独立坚持的开源人共勉。

起初

所有的个人项目都起源于想做。因为我想做,因此我建立了这样的项目。最初的目的很单纯,就是作为我学习React的一个实践。

最初选用的技术栈是React + Redux。之所以用React是为了可以共享一部分的代码到React Native, 这样的搭配会很大的减少重复逻辑的开发与后期的维护,以减少成本。

而事实是我在两者共存与中间代码上花费了很多的精力,写了很多中间脚本使网页版与RN版能够相互协作。

明确定位

一个项目,如果要走的长远,必须明确你的产品定位与目标用户。比如同样作为即时通讯应用, 钉钉瞄准的用户群是上班族,slack的用户群是开发者,discord的目标用户是游戏玩家,而qq的用户群是小学生。以此为原点,钉钉开发出了一系列日历,考勤,协同,已读未读,ding等功能。slack只做了大量机器人,discord专注于语音会话与大用户群组聊天,而qq则整出了很多花样。

明确自己的定位,其目标一定是解决某种痛点与缺陷,做出任何的抉择都需要考虑是不是符合自己的定位。一个明确的定位可以帮助你不会花费无意义的时间在没有收益的功能上。

时刻保持热情与思考

时刻记住,项目是为自己做的。它不是一种谋生工具,而是一种兴趣爱好。它最初应当起源于你对现状的小小不满,终止于对此兴趣的终止。不妨增加一些筹码:比如它可以作为你的游戏场,比如他可以作为你谋求更高事业渠道的凭证,比如他可以是你与你伴侣之间的小小私人空间,比如你可以这样告诉自己,你的每一行代码都有可能帮助到素未蒙面的陌生人,而他会感谢你的无私付出。这些会帮助你时刻保持热情与动力,而不至于让这个项目半途而废。

维护自己的门面

如果一个项目想要宣传,一个门面则非常重要。这可能是一个README, 一片文章,一个官网。但是如果没有任何说明,告诉别人这是什么,你在做什么。又能期待谁来阅读你的源码呢?

自己是第一用户

对于个人项目来说,自己才是自己产品最忠实的用户,只有这样才能不断发掘产品的不足,并不断改善。如果自己都不想用自己的产品,那么这个产品还有存在的必要么?

开发

回到技术细节:

因为技术在不断更新。而有的库又逐步步入生命周期的终结,很多情况不得不花费时间去迁移。

TRPG Engine经历过几次大的迁移/迭代:

  • 原来TRPG Engine的后端是使用的node-orm2. 然后这个库被弃用了。因此花了很大经历将其迁移到sequelize,包括代码的修改与数据库的变更。这个没什么好分享的,纯粹是当时的选择有限,而库的生命周期比较短。
  • 最早的TRPG Engine使用的是用纯粹的js写的,但是随着业务的不断增多,代码复杂度的变高,我选择使用Typescript对我的项目进行重构。从长远来看这一步是非常值得的,TS的类型系统长远的帮助到了一个项目的健康发展。(其实有点小遗憾,从目前的角度来看flow在一些细节做得比ts更加好)。
  • React ClassComponentReact Hooks: hooks无疑拥有更高的抽象性,使用Hooks能抛弃HOC这种很难被typescript支持的写法。同时更方便代码的复用,特别是对于React和React Native代码并存的项目来说。
  • 前端重构, 重写界面。原来的TRPG Engine是参考的钉钉设计的,但实际的迭代中,频道的需求更为突出。即需要由原来的单层消息列表变成双层消息列表。因此花费了几个月时间写了一版类似于Discord设计的新版页面,并且颜色也从原来的亮色变成保护眼睛的暗色。

善用单元测试

为了一个项目的长远发展,测试用例与持续集成都是必要的。特别是单元测试,在很多时候能帮助到项目的提前预知问题。坚持每个bug都有一个对应的单元测试用例,防止再次出现类似的问题。

TRPG Engine 虽然开始写测试用例比较晚,但是一旦有机会就会补充一些测试用例,以防止出现一些边缘问题。

目前TRPG Engine前端有223个测试用例,后端有284个测试用例,还远远不够一个项目的健康发展。

学会数据迁移

为了方便数据库的升级,自己fork了一个sequelize-auto-migrations库用于生成数据库的迁移脚本, 因为原作者已经不维护了。

数据库迁移脚本可以保证在任意环境下都能生成正确的数据库格式。这对于关系型数据库来说非常重要。

兴趣是原始驱动力

与商业项目不同的是,个人项目可以把一切自己感兴趣、想做的东西都想办法以某种形式糅合到自己的项目中。

TRPG Engine就拥有这些有意思的系统:

  • 基于xml描述与js沙盒的动态计算的表单系统
  • 基于slate实现的笔记系统与富文本输入框
  • 基于meta信息的动态面板
  • 基于BBCode的消息解释器

运维

为了维护与运营这个项目,唯一的支出就是在服务器和OSS上了,零零散散也花费了几千元在上面了。一台2核4G的主服务器, 在主服务器上运行Mysql, Redis与Mysql。另外还有一台1核1G的小型服务器作为语音服务器与测试服务器。前端的代码都是本地编译好后提交到第三方对象存储上,这样用户的访问会更加迅速与快捷。

赞助?

曾经也考虑过要不要和别人一样放出二维码,看看能不能获得一些打赏,后来想了想还是算了。一方面没有多少的关注,我本人也比较低调不善于宣传。另一方面感觉,掺杂了利益后的开源项目,总感觉失去了纯粹性。爱好只能是爱好,如果成为一种牟利方式,那么可能就不像当初那么纯粹了。

感谢

最后感谢所有开源作者的无私奉献,正是你们存在才能让这个互联网世界变得更加精彩。

从 Webpack 到 Snowpack, 编译速度提升十倍以上——TRPG Engine迁移小记

动机

TRPG Engine经过长久以来的迭代,项目已经显得非常臃肿了。数分钟的全量编译, 每次按下保存都会触发一次10s1m不等的增量编译让我苦不堪言, 庞大的依赖使其每一次编译都会涉及很多文件和很多包,长时的编译时间大大降低了开发效率与迭代速度。

优化方式

经过一段时间的考察,我选择了Snowpack作为解决方案。与Webpack不同的是,除了第一次的全量编译以外,Snowpack的增量编译不会涉及到庞大的node_modules文件夹, 准确来说只会编译变更文件本身。甚至于如果没有对依赖进行变更,下次的全量编译会直接动用之前编译的文件缓存,不需要花时间等待node_modules的编译。

为什么会这么快?这是由于Snowpack本身的实现与设计哲学有关的。相比Webpack, Snowpack利用了现代浏览器的本身的module系统,跳过复杂的模型之间的组织编译过程而只关注于变更文件本身的编译,这样当然快了。

Snowpack官方的一张图来说:

snowpack的最小编译单位是文件,而webpack的最小编译单位为chunk, 而chunk还需要额外的计算, 不论是编译部分还是编译后的组装部分。snowpack的设计逻辑天生决定了她的速度。

优化前(使用webpack):

全量编译:

增量编译:

全量请求用时:

优化后(使用snowpack):

全量编译:

增量编译:

(看不到编译用时,但是体感在1s内. 而且该效果在电脑运行其他应用时更加显著)

全量请求用时:

  • 使用http1

  • 使用http2

以上测试是保证电脑在空闲时间,且保存与操作内容为同一文件

该用时已经是平时操作的最快时间,为此我的MBR重启了一次强制清空了swap空间, 实际表现会更加显著

因为文件依赖于浏览器的耗时,而浏览器需要串行请求依赖,因此耗时会更加长

但实际使用中使用snowpack会更加优秀。因为其相比webpack会大大节约电脑资源。在webpack编译时会占用大量的电脑资源,会影响到其他操作

遇到的坑与解决方案

TRPG Engine算是非常经典的Webpack应用了, 使用了各种Loader。光通用配置就有250+行,各种优化配置,各种 alias。等等长时间迭代积攒下来的配置,因此毫不意外的会遇到很多问题与坑。

以下是我遇到的问题与解决方案:

  • 问题1:
    • 入口文件使用的是HtmlwebpackPlugin编译的handlebars文件,而snowpack不支持handlebars文件作为入口
    • 解决方案:重写一个snowpack专用的入口文件。使用handlebars主要解决的是dll的问题,snowpack不需要处理这部分的优化因此直接跳过
  • 问题2:
    • snowpack加载文件策略与node不同。有同名文件和文件夹会优先使用文件夹的index.js作为路径解析。具体看现象可以参考这个讨论: https://github.com/snowpackjs/snowpack/discussions/1320
    • 解决方案:改名字,让文件夹与文件名不会出现重复。包括同名但是大小写不同的问题,因为底层是nodefs.stat实现,在大小写敏感的系统下依旧会视为同名
  • 问题3:
    • TRPG Engine不但有web端,还有react-native端,而react-native是无法被正常解析的。我只想要处理web端的开发环境使用snowpack优化开发体验
    • 解决方案: exclude配置手动过滤
  • 问题4:
    • tspath不支持,虽然有了@snowpack/plugin-typescript但是不支持tspath。
    • 解决方案: 手动写了一个自动解析的逻辑将其变成对应的alias加到配置上
  • 问题5:
    • 在css中引入了字体文件,但是无法正常加载。因为snowpack无法正确识别url指定的资源并将其打包(webpack是使用css-loader来实现的)
    • 解决方案:
      1
      2
      3
      scripts: {
      'mount:font': 'mount src/web/assets/fonts --to /main/fonts',
      },
  • 问题6:
    • 对于一些特殊的写法我不想影响webpack的实现但是snowpack不支持这种写法。比如使用externals实现的配置引入, 比如DefinePlugin实现的process.env(在snowpack中必须使用import.meta.env), 再比如require的使用
    • 解决方案: 我实现了一个snowpack-plugin-replace插件用于将这些东西全部替换成我想要的代码。具体使用如下:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      [
      'snowpack-plugin-replace',
      {
      list: [
      {
      from: /process\.env/g,
      to: 'import.meta.env',
      },
      {
      from: `require("../../package.json").version`,
      to: '"0.0.0"',
      },
      {
      from: `const resBundle = require("i18next-resource-store-loader!./langs/index.js");`,
      to: 'import resBundle from "./langs/zh-CN/translation.json"',
      },
      {
      from: 'import Config from "config";',
      to: `const Config = ${JSON.stringify({
      sentry: require('config').get('sentry'),
      })};`,
      },
      ],
      },
      ],
  • 问题7:
    • rollup抛出无法解析this的警告
    • 解决方案: 使用context指向window来移除警告
      1
      2
      3
      4
      5
      installOptions: {
      rollup: {
      context: 'window',
      },
      },
  • 问题8:
    • snowpack打包目标路径与原有的build文件夹冲突
    • 解决方案: 修改输出目录为.snowpack并在gitignore中添加该文件夹
      1
      2
      3
      devOptions: {
      out: '.snowpack',
      },
  • 问题9:
    • 使用@snowpack/plugin-typescript内部包对全局变量的声明会出现重复声明的报错
    • 解决方案: tsconfig"skipLibCheck": true
  • 问题10:
    • 现有的依赖需要@babel/plugin-transform-runtime提供的helpers作为全局依赖
    • 解决方案: 经检查是用到了regenerator功能,手动安装regenerator-runtime并在包前引入import 'regenerator-runtime/runtime';
  • 问题11:
  • 问题12:
    • 在使用less的import逻辑无法正常运行,这是由于snowpack的具体实现决定的。
    • 暂时无法解决,使用snowpack-plugin-replace将其替换为css文件导入作为临时解决方案, 见讨论: Github 使用@snowpack/plugin-run-script插件将其手动编译后放在公共文件中。可以参考这个commit

总结

Snowpack虽然作为一个新兴的打包工具,目前尚不是非常完善, 功能也没有webpack这样丰富与齐全。但是它的新的打包设计对于有一定规模的前端应用还是非常优秀的。能极大提升开发效率。不失为一种好的解决方案。当然最后输出还是需要使用webpack对其进行一定的优化,毕竟原生的module支持目前浏览器的支持度还没有达到覆盖一个理想的地步https://caniuse.com/es6-module

最后这是我最后提交的pr

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

React 注意事项

Inline Style 内联样式

内联样式有一些简写操作

比如:

1
<div style={{height: 10}}></div>

转化为

1
<div style="height: 10px"></div>

在jsx中会自动加上px单位。但需要注意的是, 并不是每种属性都会自动加上单位的。容易出问题的比如lineHeight

这里有一个列表,以下属性不会自动加上px

  • animationIterationCount
  • boxFlex
  • boxFlexGroup
  • boxOrdinalGroup
  • columnCount
  • fillOpacity
  • flex
  • flexGrow
  • flexPositive
  • flexShrink
  • flexNegative
  • flexOrder
  • fontWeight
  • lineClamp
  • lineHeight
  • opacity
  • order
  • orphans
  • stopOpacity
  • strokeDashoffset
  • strokeOpacity
  • strokeWidth
  • tabSize
  • widows
  • zIndex
  • zoom

参考文章: Shorthand for Specifying Pixel Values in style props

又拍云整站网页存放到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的特性请查阅官方文档