前几天我更新了一篇简单技术总结之后,不少人都对里面的技术细节感兴趣,问我具体是怎么实现的?
于是为了给群友答疑解惑,接下来陆续会聊聊我自己的实现,这篇文章的这个方案已经运行了很多年了,很久没有更新,居然有一天会被人问到,也是比较欣喜的。当然,出于自身水平的限制,目前肯定有更好的方案,所以也欢迎提出建议和意见。
今天先来解答小伙伴第一个小问题,文章同步 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
进行文件内容的匹配提取,匹配 ![alt](href)
成功后,获取后面的本地引用地址,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 }}