此页面需要javascript支持,请在浏览器中启用javascript

技术详解: 利用CI同步文章以及多端发布

ci/cd
markdown
共2804个字,阅读时间 14 分钟
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接: https://icebreaker.top/articles/2023/3/3-article-sync-detail

Image

技术详解:利用 CI 同步文章以及多端发布

前言

前几天我更新了一篇简单技术总结之后,不少人都对里面的技术细节感兴趣,问我具体是怎么实现的?

于是为了给群友答疑解惑,接下来陆续会聊聊我自己的实现,这篇文章的这个方案已经运行了很多年了,很久没有更新,居然有一天会被人问到,也是比较欣喜的。当然,出于自身水平的限制,目前肯定有更好的方案,所以也欢迎提出建议和意见。

文章的同步

今天先来解答小伙伴第一个小问题,文章同步 CI。

目前我实现的效果是,在一个 github私有仓库里,写文章,格式为 markdown,提交到云端后,就自动呈现在我的博客网站和博客小程序里。

同时,如果要对已经存在的文章,进行内容上的修改,或者隐藏和显示,所做的也只需要修改 markdown 文件内容,然后 git commit & push 就可以了。

实现的细节

思路

看到这个方案,下意识就想到了一种实现思路:即我们只需要上传文章内容到数据库,然后在 h5weapp 应用里面,各自装一个适配的 markdown 渲染器,再各自调用后端接口,获取内容直接渲染呈现就行。

于是立即动手开干,先设计数据库,再实现一下后端,最后再写个前端,然后就实现完成了。在实现的时候就会发现,创建一篇文章,并保存到数据库是很容易的,流程无非就是 fs 读取一下内容,提交到 createArticle 接口就完成了。然而,这个方案并没有解决文章的修改和删除问题。

那么,如何让修改后的文章pushremote的时候,也让程序知道是哪一篇文章被修改了,从而进行相应的数据库操作呢?显然基于文章标题或者内容的查询都是不妥的,因为它们都有被修改的可能。

所以这种情况下,我们必须给每一篇文章,设置一个唯一的 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 文件的 templatescriptstyle 区域块?

当然这个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?这源自于 winlinux/unix 这种默认的 EOL(End of Line) 的不同 (其实就是老生常谈的\r\n\n问题),这会导致在不同的系统上,文件摘要计算结果的不一致。所以我们需要预先 normalize 一下,这是使用 windows 会遇到的坑点之一,之二是 BOM

接下来要做的,就是把这些文章结果进行分拣,分拣出哪些需要 insert, 哪些需要 updatedelete 的。这里可以在服务端写一个同步前的预检接口,返回数据库里文章数据,构建成一个这样结构: 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 地址不就有了吗?本地替换一下就行。

推荐有闲钱的同学,可以这样搞。不想花钱的,可以去使用一些免费图床。

终于到了 CI 的部分了

没想到文章的 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 }}