社区/文章分享/教你用云开发打造一个全平台自动发布的博客体系

教你用云开发打造一个全平台自动发布的博客体系

对于开发 GG 来说,云开发不仅仅是一个云服务,更是帮助他们实现心中梦想,脑中灵感的利器,这不,这就有个开发 GG 拿云开发结合 Git 打造了一个可以自动发布、构建,并进行多端发布的博客体系。只要简单写一篇文章,就可以实现一次编写,多平台自动发布。

1 背景

本文通过对 markdown 内容发布、同步、展示由浅入深的分析与实践,构建出一个相对可靠的博文编写、知识沉淀工作流,精简工具的同时提升阅读体验,更好的记录、分享和交流传播。

2 需求分析

  • 专注于用 Markdown 写文档,为了实现正常解析,使用通用语法支持;
  • 一端书写,多端同步:小程序、静态站点,高效的持续集成;
  • 快速的资源加载,优雅的排版。

但是要明晰项目边界:

  • 不需要满足随时随地写文章,因为随时随地写的大部分是随笔、记录一类的帖子,若要呈现出来,必然要经过整理;
  • 不需要自定义主题风格,博客就主体业务类型(除了评论、点赞、收藏)而言受众个性色彩不强。

3 系统设计

3.1 概要设计

3.1.1 架构设计

主要思路是本地编辑文章,通过 git 进行增删改管理,通过云端同步构建到小程序和静态站点。因此,这种思路适用于静态构建 markdown 文档的框架如 hexo、jekyll 等。
图3-1 主流程图

3.1.2 技术选型与开发框架

在开发框架上,由于首次应用于微信小程序,可能存在未知问题,故使用原生开发,不使用多端或其他预编译框架。在小程序 UI 上,参考但不依赖 WeUI 组件库,因由于封装不必要的特性可能造成代码包的冗余。

类型 方案 备注
代码托管 Coding github api 访问较大概率慢且不稳定
云开发 腾讯云 TCB 含小程序云开发服务
持续集成 Coding CI 使用 Jenkinsfile 定义 pipeline
静态托管 腾讯云 COS 也可直接使用云开发提供的静态网站托管,使用对象存储配合内容分发加速
Markdown 解析 markdown-it 也可使用 markdjs,但 markdown-it 支持拓展插件
富文本渲染 parser 比原生 rich-text 功能丰富且效果稳定

3.1.3 界面设计

限于篇幅,界面设计的部分就不在这里详细说明了,如果你感兴趣,可以点击底部的阅读原文查看。

3.1.4 开发规范

有以下几点原则:

  • 渐进式,先实现基本功能,再考虑抽离和组件化;
  • 能用简单的逻辑实现就不抽离组件,能使用成熟库就不自行创建组件,能通过配置或迁就性使用就不修改外部库以保证平滑更新;
  • 对于功能实现的方式,要考虑服务角色,权衡计算复杂度、网络延时和用户感知程度:

小程序端做简单计算

  • canvas 绘制海报
  • 基本格式转换

服务端(云开发)做复杂处理,非实时性计算,或可预生成的内容

  • markdown 转 html
  • 目录
  • 对于读写数据库,尽量将写操作放在云函数中。

3.2 详细设计

3.2.1 数据源

安全校验,保证云函数触发来源及方式可信:

// 查看请求头
if (!req.headers['user-agent'].includes('Coding.net Hook') ||
    !('x-coding-signature' in req.headers) || req.headers['x-coding-signature'].indexOf('sha1=')
    !('x-coding-event' in req.headers) || 'POST' !== req.httpMethod ) {
  return false;
}
// 计算和比对签名
const theirSignature = req.headers['x-coding-signature'];
const payload = req.body;
const secret = process.env.HOOKTOKEN;
const ourSignature = `sha1=${crypto.createHmac('sha1', secret).update(payload).digest('hex')}`;
return crypto.timingSafeEqual(Buffer.from(theirSignature), Buffer.from(ourSignature));

在每次 commit 推送新的代码时,WebHook 会 push 以下信息(限于篇幅,略去非必要信息)

{
  "ref": "refs/heads/master",
  "commits": [
    {
    "id": "8a175afab1cf117f2e1318f9b7f0bc5d4dd54d45",
    "timestamp": 1592488968000,
    "author": {
      "name": "memakergytcom",
      "email": "me@makergyt.com",
      "username": "memakergytcom"
    },
    ...
    ]}
  ],
  "head_commit":{
    "added": [
      "source/_drafts/site.md"
    ],
    "removed": [],
    "modified": [
      "package.json",
      "scripts/fix.js",
      "source/_posts/next.yml",
      "source/_posts/typesetting.md"
    ]
  },
  "pusher",
  "sender",
  "repository"
}

保持最新状态故关注"head_commit"中的 added,removed 和 modified。这些信息包含了本次提交产生的变更,可以基于遍历这些变更状态,同步云数据库。但由于可能包含了非文章文件的变更,也可能非目标分支,故需要筛选:

if ("refs/heads/" + branch === ref) {
  if (filePath.indexOf(dirPrefix) || filePath.slice(-3) !== ".md") {
    // 路径前缀和文章后缀
    continue;
  }
}

要建立数据库文件与 git 仓库文件的关联,由于每次 commit 的文件没有唯一 id 信息,可以通过文件名来建立联系,将文件名作为 slug 字段(主键)

let slug = filePath.match(new RegExp(dirPrefix + "([\\s\\S]+)\\.md"))[1];

由于 Push 事件不包含文件内容,需要通过 api 发起请求

await axios({
  url: `${baseUrl}/${branch}/${filePath}`,
  method: "get",
  headers: {
    Authorization: `token ${process.env.CODINGTOKEN}`, // 个人令牌
  },
});

3.2.2 数据处理

提取文章信息:
由于要求在 markdown 开头通过 yaml 格式写明基本信息,故在获取到文件内容(String)后需要提取。

const matter = require("hexo-front-matter");
let {
  title,
  date,
  tags,
  description,
  categories,
  _content,
  cover,
} = matter.parse(data);

其中 cover 字段(封面图)也可不声明,而通过文章首图来获取

let cover = _content.match(/!\[.*\]\((.+?)\)/);

markdown 解析 html:
小程序端环境与传统网页有区别,让 markdown 渲染在本地进行,其中还需要先转为 html。为了减少渲染时间,这一步在云端提前进行:

const md = require("markdown-it")({
  html: true, // 允许渲染html
}).use(require("markdown-it-footnote")); // 通过脚注引用生成参考文献

生成目录时,为了便于自定义和保持一致,章节自行标号。为了便于操作,目录不会解析到主体 html 中。而 markdown-it-anchor 插件会使用 header 的值作为 id,但 id 不能以数字开头,不能含中文及 encodeURIComponent(中文),但可以含-,需进行转换。

// 为<h>标签插入id
id = "makergyt-" + crypto.createHash("md5").update(title).digest("hex");
// 获取所有h2-h4生成目录列表
const { tocObj } = require("hexo-util");
const data = tocObj(str, { min_depth: 2, max_depth: 4 });

3.2.3 数据同步

在小程序的文档中,触发云函数可以通过 http api(invokeCloudFunction)的方式。但是 invokeCloudFunction 需要关键的 access_token,需要两小时内刷新获取,webhook 无法提前获知。考虑设置中控服务器统一获取和刷新 access_token,webhook 首先向中控服务器发起请求,再向云函数请求,但这样显然是不可能的,因其只能 push 一个地址一次,没有上下文。其间再加一个中间函数,那么这个中间函数又放在哪里,如何请求...(同样需要 access_token)

这时,在腾讯云-云开发控制台,发现可以直接通过"云接入 HTTP 触发方式"触发云函数,这样就可以直接该地址作为 WebHook 的 Url。但需要关注业务和资源安全[1],上文在处理 webhook push 事件时已经做了安全检验,可以再将 Coding 的 request domain 加入到 WEB 安全域名列表中。

获取到文章信息和内容后就可以同步到云数据库的相应集合中,这里循环中使用async/await遍历,为了在每个调用解析之前保持循环,只使用for...of进行异步[2]

for (const file of added) {
  await db.collection("sync_posts").add({
    data,
  });
}
for (const file of modified) {
  await db
    .collection("sync_posts")
    .where({
      slug,
    })
    .update({
      data,
    });
}
for (const file of removed) {
  await syncPosts
    .where({
      slug,
    })
    .remove();
}

图3-2 数据同步流程图

3.2.4 文本渲染

几乎不太可能将原内容原封不动显示出来, 经过 markdown-it 渲染后的 html 字符串没有插入任何样式,直接测试(组件根据标签默认提供样式)效果如下:

方案 效果
rich-text 代码块缺失,长内容被截断
wxparser 间距过大,表格、代码块被截断
towxml 代码块被截断
wemark 代码块与引用部分不换行拉宽
Parser 表格溢出

Tips: 注意到腾讯 Omi 团队开发的小程序代码高亮和 markdown 渲染组件Comi,实际上采用模板引入的方式使用。考虑随后实测效果和对比渲染速度。

相比之下,都会出现溢出组件边界,产生横向滚动条问题。在使用上,存在不支持解析 style 标签缺陷[3]

而 Parser 可以通过控制源 html 样式的方法解决这种问题

var document = that.selectComponent("#article").document;
document.getElementsByTagName("table").forEach((tableNode) => {
  var div = document.createElement("div");
  div.setStyle("overflow", "scroll");
  div.appendChild(tableNode);
  div._path = tableNode._path;
  tableNode = div;
});

同样可以预先设定 html 中标签样式来影响渲染效果,这样就可以改变字体大小、行高、行间距等,以适应移动端阅读。

//post.wxml
<parser id="article" tag-style="{{tagStyle}}"/>
// post.js
tagStyle: {
  p: 'font-size: 14px;color: #353535;line-height: 2;font-family: "Times New Roman";',
  h2: 'font-size: 18.67px;color: #000;text-align:center;margin: 1em auto;font-weight: 500;font-family: SimHei;',
  h3: 'font-size:16.33px;color: #000;line-height: 2em;font-family: SimHei;',
  h4: 'font-size:14px;color: #000;font-family: SimHei;',
}

对于代码高亮,使用prism ,引入到该组件中。

const Prism = require('./prism.js');
...
highlight(content, attrs) {
  content = content.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/quot;/g, '"').replace(/&amp;/g, '&'); // 替换实体编码
  attrs["data-content"] = content; // 记录原始文本,可用于长按复制等操作
  switch (attrs[lan]) {
    case "javascript":
    case "js":
      return Prism.highlight(content, Prism.languages.javascript, "javascript");
  }
}

而对于数学公式 Latex,渲染引擎主要有两种:

引擎 特点
mathjax 语法丰富,渲染较慢
katex 支持语法较少,迅速,只能输出 mathml 或 html,需要搭配其 CSS and font files 使用

当然,这两种都是网页客户端渲染,在小程序端天生不可用,考虑采用服务端渲染。问题有:

  • 服务端渲染如果使用外部接口,需 encodeUrl(公式),但内部\被转义消失,需要\\,replace(/\/g,'\')无效
  • 服务端渲染如果使用 mathjax-node,其依赖项 mathjax 版本^2.7.2,需将所有\替换为\\,会经常性出现SVG - Unknown character: U+C in MathJax_Main,MathJax_Size1,MathJax_AMS, 矩阵解析错误TeX parse error: Misplaced &
  • 如何比较精准的识别 markdown 中特定标记的 Latex,不造成误处理。

考虑在 markdown 解析 html 阶段就将其转化为<img>,也是很多内容平台采取的方式,较为可靠可控。这里使用markdown-it-latex2img插件,在书写上遵循一定的规范[4]以避免误处理。

const md = require("markdown-it")({
  html: true, // Enable HTML tags in source
}).use(require("markdown-it-latex2img"), {
  style: "filter: opacity(90%);transform:scale(0.85);text-align:center;", // 优化显示样式
});

3.3 静态托管

为 git 库设置构建计划,以使每次提交后同步到对象存储。这里使用 hexo 作为构建框架。
图3-5 静态托管流程
构建后自动刷新 CDN,

// refresh_cdn
const Key = decodeURIComponent(event.Records[0].cos.cosObject.key.replace(/^\/[^/]+\/[^/]+\//,""));
const cdnUrl = `${process.env.CDN_HOST}/${Key}`;
CDN.request('RefreshCdnUrl', {
    'urls.0': cdnUrl
}, (res) => {
  ...
})

4 系统实现

4.1 数据库

文章:

sync_posts = [
  {
    _id: String,
    createTime: String,
    slug: String,
    title: String,
    tags: Array,
    description: String,
    cover: String, // url
    content: String, // html
  }
]
// 安全规则
{
  "read": true, // 公有读
  "write": "get('database.user_info.${auth.openid}').isManager", // 仅管理员可以写
}

用户收藏

user_favorite = [
  {
    _id:String,
    userId:String,// openid
    postId: String,// 在表中加入冗余数据直接查询
    createTime: Date
  }
]
// 安全规则
{
  "read": "doc._openid == auth.openid",// 私有读
  "write": "doc._openid == auth.openid"// 私有写
}

用户信息

user_info = [
  {
    _id: String,
    _openid: String,
    ...userInfo,
    isManager: Boolean,
  }
]
// 安全规则
{
  "read": "doc._openid == auth.openid", // 私有读
  "write": "doc._openid == auth.openid"// 私有写
}

4.2 登录

4.2.1 普通登录

使用云开发后,无需通过 wx.login 获取登录凭证(code)进而换取用户登录态信息,因为每次调用云函数时已经附带调用者 openid。由于可以直接通过 open-data 展示用户信息(不论是否授权),一些小程序因此绕过用户登录;有些小程序通过授权用户信息后保存到数据库,后续操作均使用数据库信息,无法在用户变更信息后更新;如果用户主动通过设置页取消授权,但返回后却还在展示使用用户的信息(显示已登录)。这是因为用户态信息是通过 onLoad 获取的,返回操作时是 onShow,故此时会产生矛盾;用户在重新授权登录时选择使用其他昵称和头像,这时一些小程序会认为是新用户登录;还有一部分小程序不论业务中是否需要用户信息,均要求授权才可使用。

实际上微信小程序最大的特点就是可以方便地获取微信提供的用户身份标识,快速建立小程序内的用户体系,但上述情形均没有妥善处理用户登录这一基本策略。

基于"来去自如"[5]的原则,可以游客身份浏览,但在涉及一些需要采集和输入用户信息、或保存用户记录的功能时会要求用户跳至登录页授权获取信息,通过云函数将其与上下文中的 openid 保存到数据库,同时在回调中将用户标识生成自定义登录态缓存到本地,如果用户点击退出会将其置空。

// cloudfunction/login
const openid = wxContext.OPENID
db.collection('user_info').where({
  _openid: openid
}).get().then(async (res)=> {
  if (res.data.length === 0) {
    db.collection('user_info').add({
      data: {
        _openid: openid,
        ...event.userInfo,
        createTime: db.serverDate(),
      }
    })
  }

在下次打开小程序时,会通过检查缓存中的自定义登录态来判断用户是否登录,同样调用云函数来更新用户信息和使用信息(如打开时间、打开次数用于后续用户分析)。在下次登录时将不会弹出授权提示,当用户自行取消授权(或者 wx.openSetting 时误操作),这种情况概率很小,但一旦出现就是 Bug。如果在 onShow 中监测,会与正常 onLaunch 产生重复的逻辑。实际上,打开设置页必然会进入 onHide,可以如下监测:

// app.js
onHide:function() {
  wx.onAppShow(()=> {
    if(this.globalData.hasLogin) {
      wx.getSetting({
        success: res => {
          if (!res.authSetting['scope.userInfo']) { // 取消了授权
            this.logout() // 返回后直接登出
          }
        }
      })
    }
    wx.offAppShow();
  })
},

4.2.2 管理员鉴权

管理员即文章作者,对于管理员标识,考虑到

  • 手机号: 目前该接口针对非个人开发者,且完成了认证的小程序开放
  • openid: 不使用前是未知的,无法提前绑定
  • 用户信息是可变的不可绑定
  • 密码方式会暴露管理入口

于是暂时采取了最简单直接的数据字段标记isMaganer:true,这一字段也会用于数据库的安全规则设定。

4.3 分享

分享无非两种,直接分享到聊天和生成海报后引导分享到朋友圈,对于前者,需要考虑图片大小为 5:4,其他比例会产生空白或者裁切。这里主要分析后者。在小程序端通过 canvas 绘制导出图片比较慢,由于每篇文章分享内容基本固定,可以考虑预生成。但如果分享二维码和分享者关联,就仍然需要本地生成。这里使用组件mini-share。对于小程序码,目前采用云调用方式,这种方式只能从小程序端触发,故不能预生成。

// 处理参数
const path =
  page +
  "?" +
  Object.keys(param)
    .map(function (key) {
      return encodeURIComponent(key) + "=" + encodeURIComponent(param[key]);
    })
    .join("&");
// 组织文件名
const fileName =
  "wxacode/limitA-" + crypto.createHash("md5").update(path).digest("hex");
// 查找文件,如果找到直接返回路径
let getFileRes = await cloud.getTempFileURL({
  fileList: [fileID],
});
// 若未找到重新生成
const wxacodeRes = await cloud.openapi.wxacode.get({
  path,
  isHyaline: true,
});
// 上传到云存储
const uploadRes = await cloud.uploadFile({
  cloudPath: fileName + fileSuffix,
  fileContent: wxacodeRes.buffer,
});
// 获取返回临时路径
getFileRes = await cloud.getTempFileURL({
  fileList: [uploadRes.fileID],
});

生成二维码方式有三种,分析特性

类型 特点 适用场景
A/C 个数有限、参数较长 生成后储存 用于长期有效业务,可用于邀请码一类用户可长期关注使用的操作。
B 个数无限、参数较短 生成后可不保存,其 scene 与用户短期行为关联(如活动)。活码,与数据库关联后可以转换含义再次使用。

这里由于文章的数据库_id默认是 32 位,达到了 B 类的限制,并且还需要关联其他信息,故使用了 A 类(wxacode.get)

4.4 订阅消息

对于个人主体,只能用户经小程序发起订阅(获取下发权限)后下发一次消息,这里当用户留言时,会订阅一次回复通知,但无法发给作者(除非作者长期订阅)。由于同时需要保存到数据库,这里采用云调用实现。

// post.js
wx.requestSubscribeMessage({
  tmplIds: [TEMPLATE.REPLY],
});
// cloudfunction/sengMsg
let sendRes = await db.collection("user_msg").add({
  data: {
    _openid: wxContext.OPENID,
    msg: inputMsg,
    createTime: Date.parse(new Date()),
  },
});
await cloud.openapi.subscribeMessage.send({
  data: format(data), // 由于各种类型信息有长度格式限制,需要处理
  touser: wxContext.OPENID,
  templateId: TEMPLATE.REPLY,
});

效果

静态站点: blog.makergyt.com
备用链接: github.blog.makergyt.com

小程序:「源创智造」,其中接入了一些工具

图5-1 微信扫一扫预览小程序

5.4 开源

开源地址:整理中,将会上传于github.com/makergyt,敬请关注


  1. Tencent Cloud.云开发 CloudBase 文档[EB/OL].https://cloud.tencent.com/document/product/876/41136. 2020 ↩︎

  2. Tory Walker.The-Pitfalls-of-Async-Await-in-Array-Loops[EB/OL].https://medium.com/dailyjs/the-pitfalls-of-async-await-in-array-loops-cf9cf713bfeb. 2020 ↩︎

  3. 金煜峰.小程序富文本能力的深入研究与应用[EB/OL].https://developers.weixin.qq.com/community/develop/article/doc/0006e05c1e8dd80b78a8d49f356413. 2019 ↩︎

  4. MakerGYT.排版规约[EB/OL].https://blog.makergyt.com/typesetting/. 2020 ↩︎

  5. Tencent.微信小程序设计指南[EB/OL]https://developers.weixin.qq.com/miniprogram/design/. 2019 ↩︎