1. 程式人生 > 實用技巧 >實戰丨打造一個雙端自動釋出的部落格體系(上)

實戰丨打造一個雙端自動釋出的部落格體系(上)

專案背景

對於很多開發小GG 來說,雲開發不僅僅是一個雲服務,更是幫助他們實現心中夢想,腦中靈感的利器,這不,這就有個開發 GG 拿雲開發結合 Git 打造了一個可以自動釋出、構建,並進行多端釋出的部落格體系。只要簡單寫一篇文章,就可以實現一次編寫,多平臺自動釋出。

本文通過對markdown內容釋出、同步、展示由淺入深的分析與實踐,構建出一個相對可靠的博文編寫、知識沉澱工作流,精簡工具的同時提升閱讀體驗,更好的記錄、分享和交流傳播。

需求分析

  • 專注於用Markdown寫文件,為了實現正常解析,使用通用語法支援;
  • 一端書寫,多端同步:小程式、靜態站點,高效的持續整合;
  • 快速的資源載入,優雅的排版。

但是要明晰專案邊界:

  • 不需要滿足隨時隨地寫文章,因為隨時隨地寫的大部分是隨筆、記錄一類的帖子,若要呈現出來,必然要經過整理;
  • 不需要自定義主題風格,部落格就主體業務型別(除了評論、點贊、收藏)而言受眾個性色彩不強。
  • 控制檯入口:雲開發控制檯

系統設計

1.概要設計

1.1 架構設計

主要思路是本地編輯文章,通過git進行增刪改管理,通過雲端同步構建到小程式和靜態站點。因此,這種思路適用於靜態構建markdown文件的框架如hexo、jekyll等。

1.2 技術選型與開發框架

在開發框架上,由於首次應用於微信小程式,可能存在未知問題,故使用原生開發,不使用多端或其他預編譯框架。在小程式UI上,參考但不依賴WeUI元件庫,因由於封裝不必要的特性可能造成程式碼包的冗餘。

型別 方案 備註
程式碼託管 Coding github api訪問較大概率慢且不穩定
雲開發 騰訊雲TCB 含小程式雲開發服務
持續整合 Coding CI 使用Jenkinsfile定義pipeline
靜態託管 騰訊雲COS 也可使用阿里雲OSS,或直接使用雲開發提供的靜態網站託管,使用物件儲存配合內容分發加速
Markdown解析 markdown-it 也可使用markdjs,但markdown-it支援拓展外掛
富文字渲染 parser 比原生rich-text功能豐富且效果穩定

1.3 開發規範

有以下幾點原則:

  • 漸進式,先實現基本功能,再考慮抽離和元件化;
  • 能用簡單的邏輯實現就不抽離元件,能使用成熟庫就不自行建立元件,能通過配置或遷就性使用就不修改外部庫以保證平滑更新;
  • 對於功能實現的方式,要考慮服務角色,權衡計算複雜度、網路延時和使用者感知程度:

小程式端做簡單計算

  • canvas繪製海報
  • 基本格式轉換

服務端(雲開發)做複雜處理,非實時性計算,或可預生成的內容

  • markdown轉html
  • 目錄
  • 對於讀寫資料庫,儘量將寫操作放在雲函式中。

2 詳細設計

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": "[email protected]",
      "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}` // 個人令牌
  }
});

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 });

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();
}

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 靜態託管

為git庫設定構建計劃,以使每次提交後同步到物件儲存。這裡使用hexo作為構建框架。

構建後自動重新整理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) => {
  ...
})

總結

以上主要介紹了該部落格專案的背景、技術選型、開發框架和系統設計部分的內容,介於篇幅問題,小程式登入方式的配置、分享和訂閱訊息功能的實現將在下期推文中詳細介紹,敬請大家關注。

參考文獻:


  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 ↩︎