前几天我更新了一篇简单技术总结之后,不少人都对里面的技术细节感兴趣,问我具体是怎么实现的?
于是为了给群友答疑解惑,接下来陆续会聊聊我自己的实现,这篇文章的这个方案已经运行了很多年了,很久没有更新,居然有一天会被人问到,也是比较欣喜的。当然,出于自身水平的限制,目前肯定有更好的方案,所以也欢迎提出建议和意见。
今天先来解答小伙伴第一个小问题,文章同步CI。
目前我实现的效果是,在一个 github
的私有仓库里,写文章,格式为 markdown
,提交到云端后,就自动呈现在我的博客网站和博客小程序里。
同时,如果要对已经存在的文章,进行内容上的修改,或者隐藏和显示,所做的也只需要修改 markdown
文件内容,然后 git commit & push
就可以了。
看到这个方案,下意识就想到了一种实现思路:即我们只需要上传文章内容到数据库,然后在 h5
和 weapp
应用里面,各自装一个适配的 markdown
渲染器,再各自调用后端接口,获取内容直接渲染呈现就行。
于是立即动手开干,先设计数据库,再实现一下后端,最后再写个前端,然后就实现完成了。在实现的时候就会发现,创建一篇文章,并保存到数据库是很容易的,流程无非就是 fs
读取一下内容,提交到 createArticle
接口就完成了。然而,这个方案并没有解决文章的修改和删除问题。
那么,如何让修改后的文章push
到remote
的时候,也让程序知道是哪一篇文章被修改了,从而进行相应的数据库操作呢?显然基于文章标题或者内容的查询都是不妥的,因为它们都有被修改的可能。
所以这种情况下,我们必须给每一篇文章,设置一个唯一的 UNIQUE ID
,这个 UNIQUE ID
不必是数据主键,只需要给它一个唯一性的约束即可。
新的问题接踵而至,这个 UNIQUE ID
应该在哪里进行体现,从而让我们写的文件扫描读取脚本,在获取内容的同时获取到它呢?
这个问题第一眼,我们可能会想到这样的解决方案:这些信息可以体现在文件的名称上呀!比如我们写了一篇文章,文件名叫 如何让霸道富婆爱上我_20230302_520_true.md
,我们约定文件名格式为:${title}_${date}_${unique_id}_${valid}.md
。这样在扫描的时候,除了获取文件的内容之外,还可以获取到这些信息,再把它们插入到数据库表里,这样似乎就解决了修改和隐藏显示的问题了!
然而这个方案却有一个很大的缺陷,即扩展性很差。这体现在,一旦文件元数据(metadata
)多起来,很有可能会出现这样的文件名: 论答辩自产自销造就新时代经济永动机_20230303_666_true_1_999_fuck_your.md
,这种文件名称,本身就是一坨答辩。而且每次要修改元数据提交,还会被 git
认为是新创建的文件,显然不好。
所以,我们不应该从文件名提取内容,而应该把这些文章的元数据,放在 md
文件内容中的一块指定区域里,比如放在文章开头。再通过 json
/yml
这种格式,来把文章的元数据体现出来。然后再用特殊的分隔线,分隔元数据区域和内容区域。这样我们就可以在代码里,对文件内容块进行各自的处理,元数据区域用 JSON.parse/ js-yaml parse
进行解析,内容区域以字符串形式处理。这有没有让你想到 *.vue
文件的 template
,script
和 style
区域块?
当然这个markdown
提取分离方式,我之前找的时候也发现 gray-matter
这个 npm
包可以满足这样的需求。所以推荐使用它,将它加入你的文件扫描脚本中,去提取和分离元数据。
这里也给出一个示例:
---yml
unique_id: 20220330
title: 'icebreaker的垃圾话学习指南'
date: 2022-03-30
description: '不得不学会的垃圾话'
authors:
- icebreaker
tags:
- 'Trash-talk'
---
# icebreaker的垃圾话学习指南
...{{content}}...
按照上面的做法,我们看似解决了 CRUD
的问题,但是实现后我们会发现,这个做法效率太低了。比如你现在有 1000
篇文章,你改了 500
篇,难道你要让服务端依次把这 1000
篇的文章所有的元数据和内容,一个一个从数据库里取出来,然后和你 git
仓库里的文章,一个字段,一个字段进行比对,然后再把有改动的记录下来,执行 UPDATE
语句?
试想一下,你写了一本 绘声绘色的小黄书
准备发布,好几万个字,发布完了,一分钟后违规通知接踵而至,你需要修改内容。你改完之后,提交发布了,服务器去数据库里,取出原先那巨大的字符串到内存里,再和你提交的另外一坨巨大字符串(也在内存里)做是否相等的判断,而且这个行为还要重复很多次。服务器回复:地铁老人手机.jpg
。
显然效率太低了,我们需要更精准的做法。所以我们在进行修改文章的时候,不应该拉出这么多的数据去进行比对,而是要充分利用 文件摘要算法
进行效率上的优化。
所以这里我选择 MD5
,来对所有的元数据和内容做一个摘要,并且在一开始创建文章的时候,就把 Digest
(摘要)存入数据库中,这样在修改的时候,就只要比较摘要是否相等这个结果,相等就跳过,不相等就执行 UPDATE
。
用代码实现一下,就是:
const klaw = require('klaw')
const matter = require('gray-matter')
const md5 = require('md5')
const normalizeNewline = require('normalize-newline')
async function getArticles () {
const articles = []
for await (const { path: klawPath, stats } of klaw(
path.resolve(__dirname, 'path/to/articles')
)) {
if (path.extname(klawPath) === '.md' && stats.isFile()) {
const str = await fs.readFile(klawPath, {
encoding: 'utf-8'
})
const { content, data, orig } = matter(normalizeNewline(str))
const digest = md5(orig)
articles.push(createArticle({
authors: data.authors,
// 数据库字段叫 md5 放的就是 digest 摘要
md5: digest,
content,
uniqueId: data.uniqueId,
description: data.description,
tags: data.tags,
title: data.title
}))
}
}
return articles
}
这里有一个坑点,为什么需要 normalizeNewline
?这源自于 win
和 linux/unix
这种默认的 EOL
(End of Line) 的不同(其实就是老生常谈的\r\n
和\n
问题),这会导致在不同的系统上,文件摘要计算结果的不一致。所以我们需要预先 normalize
一下,这是使用 windows
会遇到的坑点之一,之二是 BOM
。
接下来要做的,就是把这些文章结果进行分拣,分拣出哪些需要 insert
,哪些需要 update
和 delete
的。这里可以在服务端写一个同步前的预检接口,返回数据库里文章数据,构建成一个这样结构: Map<uniqueId,md5>
,这样就可以把所有本地的文章和这个 Map
进行执行策略上的映射:
Map
中,没有当前文章这个 uniqueId
, 则意味着这条数据要新增。uniqueId
,但是这个文章的 valid
标志位被设置为了 false
, 则意味着要 softDelete
或者置一个状态的标识位,让它不显示。uniqueId
,且数据是有效的(valid
不为false
),但是 md5 digest
的值不相等,则意味着这条数据要更新。以上便是本地和远程数据库同步的逻辑。
我们可以用 regex(正则)
,或者 markdown ast
进行文件内容的匹配提取,匹配 
成功后,获取后面的本地引用地址,fs
读取之后,上传到你的 oss
,然后你的 oss
又关联了 cdn
,那对应的 cdn
地址不就有了吗?本地替换一下就行。
推荐有闲钱的同学,可以这样搞。不想花钱的,可以去使用一些免费图床。
没想到文章的 CRUD
就写了这么多的篇幅,终于到了 Github Action
的配置了,也很简单,核心就执行几段脚本,看我注释就知道都干了啥:
name: 'sync-article'
on:
# 允许手动触发
workflow_dispatch:
# 只有 main 分支的 content 下有 md 文件改动,才触发
push:
branches:
- main
paths:
- 'content/**/*.md'
jobs:
# 同步 job
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
# node_modules 缓存
node-version: 16
cache: yarn
cache-dependency-path: 'yarn.lock'
- run: yarn --production
# 扫描提取仓库文章,然后对数据库进行 CRUD 操作
- run: yarn sync
# (可选) 重新部署 website 上传到 oss 静态网站并刷新 cdn 缓存
- run: yarn website:deploy
可能你看到我这篇文章,是在我的小程序上,其实我的小程序因为一些审核的原因,已经快一年没有更新了,同时我也关闭了评论系统,为了内容安全。
还有获取文章内容的接口,一定要用 token
保护好,入参也尽量使用uuid
这种无法找到规律的字符串,不然很容易就被爬接口一下子全爬下来了(当然直接抓html还是可以的,你可以放一些垃圾在里面)。
还有,假如你使用的是那种,nextjs/nuxtjs
里一些基于文件系统的CMS npm包(比如@nuxtjs/content
),然后你还要开源的话,你应该把你的文章放入另外一个私有仓库,然后通过git submodule
把它添加进来,接着在运行和部署的时候,通过软连接,放入这类内容CMS包的指定文件夹中,这点 Github Action
也是能做到的。
- name: Checkout Self Repo
uses: actions/checkout@v3
with:
submodules: 'true'
token: ${{ secrets.PAT }}