Kalan's Blog

Kalan 頭像照片,在淡水拍攝,淺藍背景

四零二曜日電子報上線啦!訂閱訂起來

本部落主要是關於前端、軟體開發以及我在日本的生活,也會寫一些對時事的觀察和雜感
本部落格支援 RSS feed(全文章內容),可點擊下方 RSS 連結或透過第三方服務設定。若技術文章裡有程式碼語法等特殊樣式,仍建議至原網站瀏覽以獲得最佳體驗。

目前主題 亮色

我會把一些不成文的筆記或是最近的生活雜感放在短筆記,如果有興趣的話可以來看看唷!

用 Next.js 重寫整個部落格

前言

大概從 2015 年起就有陸陸續續在寫部落格,從痞客邦、Logdown 到自己用 hexo 調一調樣式,跑去 Medium,2019 年 Gastby 當時還很流行跟著寫了來玩玩。就這樣定居在 Gastby 已經有三年多了。

還是想重申一次,如果是想要開始寫部落格文章的話,隨便找個順手的能用就好,最重要的是寫文章這件事情。已經看過太多軟體工程師的部落格文章是「用xxx自架部落格」,然後就再也沒有更新;或是花了大把時間把網站架好,裡頭文章只有「hello world」或「測試」。我很慶幸自己當初有堅持寫文章分享的習慣,雖然流量不算多,但也累積了一定數量的讀者。

為什麼不用 Gatsby了?

切回正題,我覺得麻煩的地方有幾點:

  • 靜態生成:每次寫完一篇文章都要全部重新 build 一遍。(是 CI 在跑但還是很麻煩)
  • 分類:目前分類是單純去匹配 metadata,久了很容易忘記又懶得找,導致分類亂七八糟
  • 數量:幾年累積下來文章數量也來到 140 多篇了,是該想辦法用其他方式保存與修改了
  • 好玩:Next.js 這幾年下來進化不少,有很多功能都支援。而且可以完全無痛部署到 Vercel 上,對於懶得架機器的我來說就很好用。

 Gatsby 的功能雖然豐富,但最後生出來仍然是靜態檔案,而且全部的文章都需要放在本地端管理。Gatsby 雖然有提供除了 filesystem 的內容來源支援,但一樣設定起來有點麻煩,也需要符合 Gatsby 所規範的格式才能使用。如果要客製化的功能,需要一直去找套件,不然就是自己寫一個。這些成本累積起來就變成了不小的 Overhead。

因為以上原因,最後選擇了客製化更高的 Next.js

需求

整理了一下我對部落格的需求有下列幾點:

  • 從 Markdown 生成 HTML:這點對我來說是必須的。因為所有的文章都是 Markdown 格式,再加上我會用數學語法、footnote 等等。
  • 程式碼語法樣式
  • 資料庫:方便儲存跟管理文章,而且粒度最好是可以自己建立表格、建立 INDEX
  • 支援 RSS:我對 RSS 支援非常重視,部落格可以簡陋,但 RSS 一定要有。有很多讀者都是透過 RSS 來接收新文章通知。
  • 上傳圖片、影片:文章的圖片目前都放在 CDN 上面,希望有簡易編輯器支援上傳功能。
  • 多語言支援(i18n):某些文章如果想和外國社群交流時可能會加上翻譯。
  • 可以自行調整頁面

技術選擇

  • 前端頁面與後端:Next.js,實作部分後面會提到
  • 多語言支援:透過 Next.js 的 i18n 功能實作,取值部分會在後面章節提到
  • Markdown 與程式碼語法樣式轉換:透過 remarkshiki 完成
  • 資料庫:PostgreSQL,使用 Google Cloud SQL 建置。(資料庫比想像中的貴😱)
  • RSS Feed:透過 Cloud Function 與 Cloud Scheduler 定期生成
  • 靜態檔案儲存:S3,透過 Cloud Front 做 CDN
  • 部署:透過 Vercel 與 GitHub 整合,push 新的 commit 後直接部署

看到這邊應該會有人心想,怎麼靜態儲存放在 Amazon 上,剩下的雲端服務都是 Google Cloud。

自己用過 RDS 跟 Lambda 的感想是 Google 的介面跟設定比較親民一些,於是轉到 Google Cloud。但因為開部落格以來靜態檔案都是放在 S3,也就將就用了。如果之後閒來無事應該會來個靜態檔案大遷移。

實作

將文章搬到資料庫(Cloud SQL)

因為過去的文章都是以單個 Markdown 檔來撰寫,所以第一步是把檔案內容全部放進資料庫。我的設計是有兩張 Table 分別是 posts 與 categories。為了要支援 i18n,在標題、概要等欄位上用了 json 型別,雖然搜尋會比較麻煩一點,但這樣一來要加入其他語言時就會方便很多。接下來就是寫寫 SQL 將文章放入對應的欄位。值得一提的地方所有的資料庫建表格操作等等都有另外寫成 .sql 來存放,方便出事時可以 rollback,也方便重新建立。

對很多有經驗的工程師來說是常識,但我發現蠻多人不是仰賴 framework 提供的功能,不然就是在 cli 上打指令,結果要修改的時候不知所措。

Next.js

先來説說為什麼是用 Next.js 好了,雖然各大前端框架都有對應的 SSR Framework,但目前在我的經驗當中功能最豐富且整合最好的就是 Next.js,幾乎不需要太多設定就可以開始開發。來分享一些開發細節:

SSR(Server Side Rendering) 與 ISR(Incremental Static Regeneration)

Next.js 提供了三種頁面渲染方式

  • Static:在 build time 時建立純靜態頁面,透過 getStaticProps 塞入 props 渲染 HTML
  • ISR:在 build time 時建立你定義路徑的靜態頁面,如果指定的路徑不存在則使用 SSR 來渲染頁面
  • SSR:每次請求時渲染頁面,會執行 getServerSideProps 並回傳渲染的 HTML 後在前端執行 JavaScript 邏輯(加入事件監聽器等等)

對我的部落格需求來說,我想首頁與第二、三頁會時常被存取,且有可能會因為我發文而需要更新,所以採用 ISR 來實作;文章內容也是採用 ISR,前 50 篇文章會在 build time 直接建立靜態頁面,而比較老的文章則是有請求來才建立;像是 /contact 等頁面則是採取純靜態實作。

實測下來果然是靜態無敵,因為伺服器端需要跟資料庫連線,所以只要是 SSR 一定會花比較久時間。

i18n

Next.js 有內建 i18n 功能(在路由層級),可以設定不同語言轉導到不同網域,或是轉導到不同路徑。例如:

  • /zh/posts:將 locale 設定為 zh
  • /en/posts:將 locale 設定為 en

也支援自動偵測語系(透過 headers 或是 navigator.languages)。在 Server 端跟 Client 端都可以輕易拿到目前的 locale 值:

// Server 端
const getServerSideProps = ({ locale }) => {
  // current locale
} 

// Client 端
import { useRouter } from 'next/router'
const Component = () => {
  const { locale } = useRouter()
}

一般在做 i18n 時會用像是 react-intl 或是 react-i18next 等函式庫來簡化操作,但是我看了看文件覺得頭昏腦脹,在我的需求當中不會用到第三方服務,不需要假設失敗情景,Key 沒有多到要額外載入。

因此最後是自己寫了簡單的實作:

import React, { createContext, useCallback, useContext } from "react";
import { en } from "./en";
import { ja } from "./ja";
import { zh } from "./zh";
type I18nData = {
  _i18n: {
    locales: string[];
    data: {
      [key: string]: {
        [key: string]: string;
      };
    };
  };
};
export default function createI18n() {
  const datas = {
    en,
    ja,
    zh
  };
  return {
    _i18n: {
      locales: ["en", "zh", "ja"],
      data: datas
    }
  };
}

const I18nContext = createContext<{
  locale: string;
  _i18n: I18nData["_i18n"];
}>(null);

export const I18nProvider: React.FC<{
  _i18n: I18nData["_i18n"];
  locale: string;
  children: React.ReactElement;
}> = ({ _i18n, locale, children }) => {
  return (
    <I18nContext.Provider value={{ _i18n, locale }}>
      {children}
    </I18nContext.Provider>
  );
};

export const useTrans = () => {
  const { _i18n, locale } = useContext(I18nContext);
  const t = useCallback(
    (key: string) => {
      const data = _i18n.data[locale] || _i18n.data[locale];
      if (data) {
        return data[key] || key;
      }
      return key;
    },
    [_i18n.data, locale]
  );
  return { t };
};

雖然簡陋但夠用,反正這個部落格有很大機率只有我一個人在開發,有問題再修即可。直接存在 JS 雖然會導致 bundle size 大一點點,但考量目前靜態的文字檔案不大,而且首頁跟常用頁面都是使用 ISR 功能實作,有需要的話要搬到 CDN 也不是太大問題,現階段這樣就可以了。

圖片處理

在 Next.js 當中有提供 next/image 專門處理圖片載入機制以及優化。如果沒有特別處理,可能在所有裝置(手機、桌機)上都渲染同一張圖片。然而這樣體驗並不好,因為大尺寸的裝置可能會想要解析度好一點的圖片,但小尺寸的裝置給解析度太好的圖片不但看不出效果,還會浪費頻寬。

另外一件事是圖片如果沒有定義大小的話,在還沒有載入的時候不會佔用高度,但是載入完畢後會突然有高度導致 Layout Shift。如果有多張圖片的話加上網路延遲就會非常惱人。

因此在 Next.js 上非常建議使用 next/image 來處理圖片,需要明確定義寬高或是自行定義比例。另外在使用 next/image 時,預設情況下圖片會先經過伺服器處理後才會回傳。

假設在 CDN 上有 URL:https://cdn.kalan.dev/images/avatar.jpeg,實際請求會變成:/_next/image?url=${URL}&w=128&q=75 這代表透過 next/image 的請求都會先經過伺服器,由伺服器處理過後再回傳,而非直接請求到原網址。

Next.js 處理圖片時的流程

這樣做的好處是可以根據裝置需求回傳適當的格式與大小,然而這也代表伺服器的負擔會變高。由於我是使用 Vercel 部署,Vercel 預設有給出圖片優化的限額,不需要太過擔心,然而如果是自架機器的話就必須特別注意。

既然是這樣那其他人的圖片是不是也可以丟到這個 API 來優化?在 Next.js 中會要求你設定圖片的 domain,如果圖片的 domain 不符合會拒絕請求。

如果不希望圖片優化的處理跑在自家機器上,可以額外設定 loader 來定義 Next.js 如何處理圖片(例如丟給 CDN)。

remark 與 shiki

為了將 markdown 轉換為 HTML,用目前很熱門的 remark 來實作。首先是透過 unified 將 Markdown 轉換為語法樹,再透過各種套件轉換為 HTML。另外程式碼語法樣式的部分採用了 shiki,主要是因為他的主題定義跟語法定義我都很喜歡。

實際開發時有遇到一個問題是 shiki 會在執行期間載入 theme 跟程式碼語法的設定檔,然而部署到 Vercel 時因為會變成 serverless function,因此 fs.readFile 的方式似乎沒辦法正確讀取到 node_modules 裡的檔案。解決方法是手動將主題檔跟語法設定檔搬到專案裡:

const languagesPath = path.join(
  process.cwd(),
  'src',
  'utils',
  'shiki',
  'languages'
)

const langs = shiki.BUNDLED_LANGUAGES.map((lang) => ({
  ...lang,
  path: path.join(languagesPath, lang.path || "")
}));

export default async function convertMdToHTML() {
  const nordTheme = shiki.toShikiTheme(theme as any);
  const highlighter = await shiki.getHighlighter({ theme: nordTheme, langs });
  ...
}

Vercel Edge Functions

Next.js 13 推出了實驗性功能 Edge Functions,可以跟 Vercel 的 Edge Functions 搭配使用,不需要額外做設定。

跟一般的 Serverless Functions 不同,Vercel 上的 Edge Functions 會跑在獨立的 V8 Engine 環境,因為執行環境是一樣的,所以啟動速度快很多,而且可以部署到全球節點,根據使用者所在地來決定比較近的節點,進而縮短 API 回應時間。

By taking advantage of this small runtime, Edge Functions can have faster cold boots and higher scalability than Serverless Functions.

這個功能實在太酷了,跟著官方給出的範例部署了一個能夠根據標題動態生出 OG Image 的 API,而且樣式可以透過 HTML 語法定義。如果文章沒有 OG Image 時會 fallback 到這個 API。不過使用上有些限制,因為是執行在 VM 的環境下,所以沒辦法直接執行 DB 的操作,也不確定能不能執行 HTTP 請求。

Serverless Functions

在預設情況下,當部署 Next.js 到 Vercel 上時,非靜態頁面且有採用 SSR 或 ISR 的話,Vercel 會自動生成一個 Serverless Functions。

言下之意是 Next.js 應用部署到 Vercel 後並不是跑在一台機器上,而是被拆分成了數個 Serverless Functions,且會有 Cold start 的延遲。

Next.js 部署到 Vercel 之後的示意圖

因此實作上也要注意一下一些細節:

  • 除非可以接受不一致性,否則避免用全域變數來做 In Memory 的 Cache,因為有可能沒辦法共享到其他頁面上
  • 需要理解 Vercel 對於 Response 與 Request 的限制
  • 我的應用當中會建立資料庫連線,因此為了避免一堆 Serverless Function 同時執行導致連線數暴增,我把 idle timeout 調低確保資料庫連線不會佔用太久之外,也稍微增加了連線數確保不會出錯。

Cloud Functions 與 Cloud Scheduler

RSS Feed 會定時生成,這邊我用了 Cloud Functions 與 Cloud Scheduler 來實作。花了老半天釐清了 Cloud Functions 跟 Cloud Run 的關係。Cloud Function 只能用 Google 有支援的程式語言撰寫,但 Cloud Run 背後跑在 Docker 上,因此只要有 Image 就可以使用。Cloud Function 現在有第二代,背後似乎也改用 Cloud Run。

像跑 RSS Feed 這種寫寫腳本就好但需要一點時間的工作很適合用 Cloud Functions 跟 Scheduler 搭配,唯一要注意的地方是如果會佔用比較多 Memory 的話記得調整一下 Cloud Function 的 Memory 大小。

使用者可以透過不同事件觸發 Cloud Functions,例如透過 Pub/Sub 或是直接用 HTTP 呼叫。我在這邊是用 Pub/Sub 實作,當接收到 generate-rss 事件時就會執行。

生成 RSS Feed 的示意圖

因為 Cloud Scheduler 只有一項工作所以是免費的,然後 Cloud Functions 因為不會被外部存取,因此流量也會在免費額度裡頭,對個人專案來說還是很方便的。

AWS S3 與 CDN 設定

AWS S3 是從部落格開張以來就有在用的服務,搭配 CloudFront 一起使用。原本是用預設提供的網域名稱,後來研究了一下才知道其實可以掛自訂網域,而且 SSL 證書可以透過 AWS Certficate 直接弄一份,只要將 CNAME 設定好剩下的 Amazon 會自動幫你搞定。

另外如果是用 CDN 給外部拿靜態資源的話,建議關掉 S3 存取權,確保所有流量都只能從 CloudFront 過來。一方面是比較安全,一方面是如果不小心被 DDoS 的話至少 CloudFront 有一層保護,可以透過 Web ACL 來擋掉 IP 位址。

詳細設定過程如下:

  • 到 CloudFront 的介面下建立分佈

CloudFront 的控制介面示意圖

  • 如果來源是 AWS S3,選擇「原始存取控制設定」後會幫你加上儲存政策。然後在 S3 控制介面當中我把封鎖公有存取權開啟,讓 ACL 強制無效。

CloudFront 控制介面的「原始存取控制設定」

  • 從 AWS Certificate Manager 請求 SSL 憑證。透過 DNS 驗證的話建立憑證後會給你 CNAME 名稱和 CNAME 值,把他填進去 DNS Record 證明你真的擁有該網域。

AWS Certificate Manager 的控制介面

  • 在 CloudFront 的設定裡頭加入自訂網域,這樣一來就大功告成!在 AWS Certificate 公有憑證本身是不需要付費的。以後靜態資源都可以透過 https://cdn.kalan.dev 存取,看起來舒服多啦。

Slate Editor

(開發中)

我希望做一個符合自己需求的 Markdown 編輯器,需要客製化程度高一點的套件,因此最後選擇了 Slate。Slate 設計上很有彈性,但幾乎所有功能都要自己實現,而且要去理解背後的 Node 構造,因此打造成自己想要的樣子需要花比較久時間

目前實作了一些我一直很想要的功能

  1. 透過拖拉跟複製貼上觸發上傳,把圖片上傳到 CDN 回傳連結。
  2. 按下特定快捷鍵後會直接呼叫 Translation API 把文字翻譯成其他語言,這樣一來針對簡單的段落就可以直接翻譯,複雜的段落再自己努力翻。

這部分等有完整的成果之後再與大家分享。

功能與優化

到這裡,一個堪用的部落格系統總算是完成了,未來還有一些想要修正跟優化的地方:

  • 補測試跟 E2E 測試,目前文章內容太多了,一篇篇檢查很麻煩
  • 減少 DB Query,將文章內容等快取後放到 CDN 上。
  • 減少 JavaScript Bundle Size
  • 全文搜尋
  • 上傳檔案後直接塞入資料庫
  • 匯出與匯入文章
  • 待續

上一篇

C 語言當中的字串處理

下一篇

排版時有用的 Pseudo 類別

如果覺得這篇文章對你有幫助的話,可以考慮到下面的連結請我喝一杯 ☕️ 可以讓我平凡的一天變得閃閃發光 ✨

Buy me a coffee