上篇文章的日程规划中有提到我要开发个账本的产品,本来打算开发完了再整理文章的,但意识到到时候很可能很多东西都忘记了,所以趁有空,赶紧先记录一些。。

前言

首先做账本这个东西,不仅仅是做账本。我希望通过做这个产品,能够串起来整个研发流程,了解每一个之前不熟悉的领域,能给自己带来技术层面的收获,在这个基础上,如果能够把账本这个软件做出来,解决我记账的问题的同时,也能够给别人带来便利,这样是最好的。

规划

产品形态

一款软件产品无外乎有 app,web 网页,小程序三种形态。

考虑到记账的这件事,对于大部分目标用户来说,都是在 app 或者 小程序中完成的。而让用户在手机中安装 app 成本很高,且目前也没有服务器资源,所以先使用免费的微信云服务,把小程序做起来。

UI

之前花了好几个夜晚,在网上找免费的 sketch 或者 figma 模板,结果都不太满意。后来发现某款记账 App UI 挺好的,我打算先抄一下,在学习使用 figma 画他的 UI 图的时候,发现实在太费劲了。所以目前 UI 就先不考虑,先把整个功能实现了,再打磨 UI

技术选型

Web 网页和小程序是前端技术栈无疑,只是 Vue 和 React 选哪个的问题。由于不太喜欢 Vue 模板,所以选择对 TypeScript 支持良好的 React。

小程序选择基于 React 技术栈的 Taro 框架,框架用的人多,遇到一些坑相对会少一些。

App 首选肯定 React Native,但一直用前端技术栈做这些没啥意思,到时候也可能尝试使用 flutter。

架构设计

账本是一个比较注重隐私的产品,所以应该具备“本地模式”和“网络模式”两种形态。

此外,开发时要注意架构设计,可以便捷的切换本地模式和网络模式,在没有服务器的情况下,应用也可以正常使用。

开发可能涉及多包依赖,使用 lerna 来解决问题

考虑代码的多仓库管理,使用 git sub-module ,配合 lerna 进行开发

自动化部署

从学习和提高效率,规范代码,自动化构建、部署等角度来讲,需要部署一套 gitlab。

恰巧,吃灰许久的树莓派可以排上了用场。希望能他能担此重任。

环境搭建

首先确保网络是畅通的翻过去的环境,否则很多依赖装不上,坑很多。

Gitlab 安装

Gitlab 打算装载树莓派上,我的树莓派型号是 3B+,有 1G 的内存, 16G 的磁盘空间,装的 Ubuntu 系统。

Gitlab 在安装过程中坑点较多,安装时使用默认官方源,进行 apt-get install 安装,安装后软件后,根本运行不起来,后来查阅文档,发现只推荐在树莓派 4 的 4G 内存版本以上进行安装。淘宝搜了一下,因为穷,只能考虑安装到我的 MacBook Pro 开发机了。

为了不污染环境,安装了 docker,在 docker 中部署 gitlab,教程在官网是有的,根据说明安装即可。

有几点需要注意的是:

  1. 网站上写的默认给 gitlab docker 对外暴露的端口是 80、443 和 22。这几个端口在 MacOS 下是有限制的,建议都换成其他端口。
  2. 默认设置了 hostnamegitlab.example.com,这需要你更改本机的 hosts 文件,进行配置。
  3. 我在实践时 docker 给了 4G 的内存,4 个 CPU 核,gitlab 安装是失败的。给到了 8G 内存,才安装成功。

Gitlab-Runner 安装

Gitlab-Runner 是用来对仓库代码跑 CI/CD 等流程的工具。Runner 可以在本地部署,也可以直接用远程的,Gitlab 官网好像是有免费提供一个 Runner 进行使用,这个对标的是 Github Action。

考虑到不让我的树莓派吃灰,我决定把 Runner 部署到树莓派中。

远程连接到树莓派上,按照官网教程,也顺利的装上了。在测试功能的过程中发现项目代码打包不了,在安装依赖的过程中就卡死了。最后重装系统,重装 Runner 问题依旧,换成了树莓派原系统也不太行。

后来才想到可能是内存卡的问题,安装依赖时,node_modules 里成百上千个小文件,很考验内存卡性能,换了内存卡后,问题成功解决了。

还有就是在 Runner 中注册后,Gitlab 网页上发现 Runner 是不可用状态,此时需要手动执行命令

sudo gitlab-runner verify
sudo gitlab-runner restart

遇到最无语的事情是在成功跑了几次任务后,给 Runner 改了标签后,YML 中 也把标签改了后,再运行任务时,显示的一直是挂起状态。把标签恢复了也不行,花了几个晚上尝试,没有结果。。。有心想看看源代码,找找 bug,奈何没这个实力和水平。。最后重装系统和软件才搞定,然后发现该标签后也能正常运行了....

其他一些坑点可以参照这篇文章

YML 脚本编写

Runner 是根据 YML 脚本进行执行的。

一个前端项目的 YML 文件中一般包含了四个 Job: 依赖安装、Eslint 扫描、打包构建和部署。

配置完四个 Job 后,发现部署巨慢无比,且在 build 阶段还总失败报错找不到依赖。尝试了很久解决不了。

观察发现每个 Job 的执行,都会重新初始化一遍项目,拉代码下来运行,node_modules 在配置完 cache 字段后,只需要安装一次。

后来想,是我自己一个人开发的话,没必要搞这么多步骤,最重要的是把部署这一步搞定就 ok..

开发

整个项目难度不大,为了学习,我还是在项目中的多用自己写的一些工具: 比如说请求库状态管理脚本管理Taro 路由管理Mock 工具文件监听等等。

1.23 日

目前进度是:

  1. 基本的界面 UI 完成初版。
  2. 接口 Mock 工具完成了初版
  3. 脚本管理工具支持子进程的运行模式
  4. 状态管理工具完善
  5. 实现了更好用的弹窗管理
  6. 实现了前端版本的接口路由,可自由切换 WxCloud、Storage、和 Server 的数据存储模式

UI 一览:



总结
虽然总体进度很慢,但在技术层面也算是小有收获。

账本的目标是要实现将数据自由存到服务器(Server)、微信云服务器(WxCloud)、和本地(Local)。怎么能很好的兼容这三者?WxCloud 和 Local 都是前端代码可以直接调用的,意思是前端就可以直接访问数据库进行操作,而 Sever 模式需要走常规的接口请求。

为了方便上层的开发,即开发时不需要感知到底运行的是那种模式,考虑将 WxCloud 和 Local 模式的调用方式和 Server 保持一致。这是是什么意思呢?

const fetchData = (options) => {
    // wxCloud 模式
    if(Env.isWxCloud) return cloudServer.get(options)

    // storage 模式
    if(Env.isLocal) return storageServer.get(options)

    // erver 模式
    return fetch(path, options)
}

export default function App() {
    const [user, setUser] = useState()

    useEffect(() => {
        // 渲染 UI 时,不需要管底层到底怎么取的数据
        fetchData('/user', { id: 1 }).then(res => setUser(res)
    }, [])

    return <div></div>
}

这就需要在前端实现类似 koa-router 之类的库,来支撑 WxCloud 和 Local 模式。动手实践后,才发现这个东西非常简单。其实就是一个 key 和 callback 的映射。

class Router {
  routers: Route = {}

  use(path, cb) {
    this.routers[path] = cb
    return this
  }

  call(options) {
    const { path, ...opts } = options
    return this.routers[path](opts)
  }

  merge(routers) {
    this.routers = { ...this.routers, ...routers }
    return this
  }
}

用法如下:

const appRouter = new Router()
const userRouter = new Router()

userRouter.use('/user', (options) => {
    return userService.getUser(options)
})

appRouter.merge(userRouter.routers)

有了前端版本的路由,上面的 fetchData 就可以改造为下面这样,从 wxCloud 数据库中取数据还是 Storage 中取数据,交由 userService 去取处理。

function fetchData(options) {
    if(Env.isServer) return  fetch(options)
    return appRouter.call(options)
}

而在 WxCloud 和 Storage 之间,为了取数据方式的一致性,简单的封装了增删查改。但是对于连表查询等高级能力,目前还没搞定。之前还研究了一下 sqlite.js,尝试在小程序的 wasm 中运行一下,折腾了很久,也搞定不定。。

除了前端的接口路由外,还想分享的一点是实现了脚本管理工具的部分脚本的子进程运行模式,为什么要搞这个呢?
我们都知道,当我们跑起一个脚本时,一般逻辑都是跑在主进程上的,我实现的脚本管理工具也是一样,它会加载配置的各个模块,自动填充配置的参数运行。本质也是运行在主线程上。

mock 工具原理本质就是开启了一个 Http Server,我设计的每个 mock 文件,都包含了一个 path 和一些 response,这也就是说我的 Http Server 中的路由是动态修改的。

mock 工具简易实现如下:

function mockServer() {
    const app = express()

    fs.readdirSync('./mock').forEach(file => {
        const { path, response } = require(file)
        app.use(path, (, res) => {
            res.json(response)
        })
    }

    app.listen(3000)
}

// mock/user.ts
export default {
    path: 'user',
    response: {
        name: '张三'
    }
}

运行起上面的代码后,将会在 mock 文件夹下找到 user.ts,加载并应用。实际开发过程中,我们可以需要经常修改 user.ts 文件,但修改后需要在下次启动项目才会生效。此时就要 ctrl c 结束脚本运行,然后再 yarn dev 开启脚本。这样繁琐且麻烦,有没有办法解决这个问题?child_process

child_process 可以在主线程中开启一个子线程去运行代码,实现起来也很简单,但有个大坑就是子线程创建的子线程,在关闭主线程后,孙子线程还会继续保留。

我在脚本管理器中设计了一个 subProcess 的参数,标志要开启子线程运行代码。当监听到 mock 文件夹下的 user.ts 文件变动,则再次运行 mock Server 的代码,此时如果发现 mock Server 已经有运行在一个子线程上,则会关了它,再开启一个。。

原理很简单,但这足足花了我两天时间。。之前实现的逻辑生成了多个子线程和孙子线程,关闭 mock server 的线程时,都不知道这个线程运行在子线程还是孙子线程上。。。说多了都是泪。。。

最终的效果非常不错:
可以看到,在我保存完文件后,mock server 自动重启,运行 curl 命名,修改生效。

核心代码:

 childprocess.spawn(
     'ts-node',
      [
          '-e', 
          `require("${module}")(...${JSON.stringify(args)})`, 
          '--skip-project'
      ], 
      {
        stdio: ['inherit', 'inherit', 'inherit']
      }
)

2.3 日

目前进度:

  1. 代码仓库整到了 Github,使用 Github Action 进行自动化构建
  2. 写了部分系统设计文档。设计了部分数据表和本地模式下的接口实现
  3. 重构代码,用状态管理工具去控制运行模式(Local、WxCloud、Server)
  4. 状态管理的 Storage 模式
  5. 本地模式下的 DB 数据初始化
  6. 本地模式初步跑通

这里本地模式是指 Local 和 WxCloud,能在前端直接操作数据库

UI 一览:

总结
首先把代码仓库切到 Github 了,电脑装的 docker 和 gitlab,运行起来资源占用太大,电脑风扇总是飞转,推完代码后,树莓派的 runner 也是跑的很慢,要几分钟才能跑完 CI,然后上传代码后预览。在一次 docker 升级后,docker gui 彻底打不开,索性直接卸载,转移仓库到 github 上了,花了一点时间研究了一下 Github Action 写了一下脚本,实现了向 delpoy 代码分支 push 代码,自动打包预览的功能。打包时间比树莓派快多了。。

然后就是写系统设计文档了,这玩意不写的话,没办法进行下去。脑子里都是一些琐碎的想法,不整理出来,无从下手写代码。设计了主要的数据表模型,存什么字段都大致罗列了一下;同时也写了一下实现了接口规范,响应的数据,传的参数等。

关于运行模式(Local、WxCloud、Server),之前没考虑好,是打算在打包的时候,根据环境变量来控制运行哪种模式,最近发现不能这么搞,应该放到前端,有个开关来控制数据存到哪里。切换开关后,状态应当是一直生效的,所以还要把状态管理和持久化数据进行结合,同样考虑到跨平台的原因,存取持久化数据可能是一个异步的过程,所以整个重构还是有一点挑战的。同时也是考虑目前没有服务器资源,所以重心放在了先将本地模式跑通。

本地模式下的数据初始化。UI 里面有一部分数据应当初始化到 DB 中,比如账单的支出类型、币种类型、资产类型等数据。所以在什么时机初始化这些数据?怎么保证发起的请求是在初始化 DB 后?怎么保证只初始化一遍数据(即下次打开应用不再初始化数据)?这些都是我在设计时遇到的挑战。最终解决方式是在调用接口时,用到了某张表,先查看索引表有没有这张表的记录,有的话,说明初始化了,没有的话,则进行初始化操作。这个流程相对符合直觉,但是要考虑的还要更多一些,比如并发请求,怎么控制只初始化一次等等。。解决方法还是之前在 axios 中看到的异步控制 Promise 的方案,简直帮了大忙。

let promiseReslove
let promise = new Promise((resolve) => promiseReslove = resolve)

解决完上述问题,整个本地模式初步跑通了,效果图见上边的 UI 一览。整个效果还是令人满意的。
昨天在调试 wxCloud 模式的时候,突然小程序 IDE 报了个错,这才发现免费的微信云开发数据库,每天只有 500 次的读写操作。然后看了一下文档,数据库同时连接数只有 5 个,这就说明数据库的读写等操作,只能放到云函数里去弄,前端改造成本较低,代码设计之初就添加了 DB 的 Connect 层,后续添加或者调整存储形式,都比较容易。

3.12 日

目前进度:

  1. 重构了浮层管理
  2. 重构了 prequest 的类型系统
  3. 重构了 use-request
  4. 完善了状态管理
  5. 完成了本地模式的基本功能,提交审核了第一个版本

总结
通过写这个小应用,发现之前写的小工具的很多不足,比如请求库需要手动的注入类型、状态管理初始化不够好用等等,花了比较长的时间来完善这些工具。

然后就是今天终于提审了第一个版本,基本功能是有了。但对于 WxCloud 和 Server 模式,不打算继续搞了。整个小程序剩下的工作,就是写业务逻辑方面的内容,基础的重复性工作,增删查改调 UI 什么的,感觉没啥意思。

最后小程序名称是: 我记个账,欢迎体验

后记

这篇文章从去年 12 月 11号开始写起,迭代了好几个版本,内容也挺多了,就不继续更新了。期间,利用业余时间也搞了很多事情。总的来说,小有收获。对于这个账本的开发,目前会告一段落。

最近有个想法,想搞一个网站或者Chrome插件什么的,可以抓取各种信息展示给用户,类似早期的今日头条。。但是专业性更强,类似 daily.dev 这个插件。