logo

鱼肚的博客

Don't Repeat Yourself

使用Serverless框架搭建博客

动机

最近在学习Serverless相关技术,打算使用Serverless框架搭建出一套博客系统,一方面学习Serverless,另一方面也尝试做一个更好的博客系统。

规划

理想中的博客,大概应该具备如下的功能、特性:

  • 有良好的页面布局,使博客赏心悦目。
  • 有良好的编辑方式,摆脱Github Pages、Jekyll中必须提交到Git仓库才能生效,Git仓库中直接查看MD文档不正常(有title、category等信息)等问题
  • 良好的扩展能力
  • 良好的访问速度
  • 支持评论
  • 有一定的配置能力,如显示广告位、友链等
  • 支持导出到公众号

技术栈的选择

  • 前端&管理前端:Serverless Component、Next.js
  • 后端:Serverless Framework
  • 数据统计:Google Analytics
  • 评论系统:Github Authorization、Serverless
  • 数据存储:DynamoDB
  • 文章编辑&导入:Gist
  • 云服务商:AWS
  • 缓存:CloudFront

这个技术栈的风险点在于,系统维护在AWS上的,在中国大陆的访问速度可能不佳。但是考虑到博客系统的内容相对固定,可利用一些缓存技术(CloudFront)优化这个问题,应也可以解决。

系统设计

基础代码结构

基础的代码结构,是一个Monorepo的结构,将前端、后端、管理端共同维护在一个Git仓库中。

使用 lerna 管理,将 frontend、backend、admin共同维护在同一个项目中。

文章的创建与发布

使用Gist创建文章,统一命名为 xxx.blog.md(以.blog.md结尾)的形式。

系统自动获取所有的Gist,并根据最后修改时间和上次同步时间,决定是否需要同步文章。

也可以手动同步文章,进入管理端,输入相应的Gist链接,即可手动同步。

评论系统

当用户要评论的时候,引导用户使用第三方系统登录(如Github),并将评论的内容存储在后台系统中。

分类和标签

分类和标签写在Gist中,文件的头部。使用Markdown注释的形式,使其在Gist中不展示,但是可被程序处理。

一篇文章应归属于一个分类,但是可以有多个标签。

开发过程

初始化项目

从Serverless Boilerplate https://github.com/aide-master/serverless-boilerplate 中创建一个项目,作为初始化的项目。

再将 frontend 复现到 admin,修改其名字,即拥有了一份初始化好的项目。

部署项目

对于Serverless + Next.js的项目来说,修改其 serverless.yml 文件中的 domain,设置成目标域名。

1admin:
2  component: serverless-next.js
3  inputs:
4    domain: ["admin", "banyudu.com"]

对Serverless Framework的项目来说,修改 serverless.yml中的customDomain中的信息

1custom:
2  domains:
3    prod: api.banyudu.com
4    staging: staging-api.banyudu.com
5    dev: dev-api.banyudu.com
6  customDomain:
7    domainName: ${self:custom.domains.${self:provider.environment.stage}} # Change this to your domain.
8    basePath: 'blog' # This will be prefixed to all routes
9    stage: ${self:provider.environment.stage}
10    createRoute53Record: true

设置好文件之后,再执行serverless (前端项目)和serverless deploy --stage prod(后端项目)即可完成部署。

这个过程中遇到了@serverless/domain中的一个bug,无法正常处理之前已经存在的证书。研究了好久才解决。

使用Github Actions自动化部署

Github中提供了自动化的CI/CD机制,可以简化我们的部署步骤。

另外考虑到国内访问AWS时经常遇到网络不畅的问题,通过Github作为部署的中间环境,也有助于加快部署。

我的方案是,通过 lerna publish 来发布正式版本,同时在Github中为新tag设置触发器,执行部署。

前端的部署脚本

1on:
2  push:
3    tags:
4      - 'frontend@*'
5name: Deploy Frontend
6jobs:
7  deploy:
8    name: deploy
9    runs-on: ubuntu-latest
10    steps:
11    - uses: actions/checkout@master
12    - uses: actions/setup-node@master
13      with:
14        node-version: 12.x
15    - run: npm i -g serverless
16    - run: npm i
17      working-directory: ./packages/frontend
18    - run: |
19        echo AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID >> .env
20        echo AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY >> .env
21      working-directory: ./packages/frontend
22      env:
23        AWS_ACCESS_KEY_ID: ${{ secrets.aws_access_key_id }}
24        AWS_SECRET_ACCESS_KEY: ${{ secrets.aws_secret_access_key }}
25    - run: sls
26      working-directory: ./packages/frontend

后端的部署脚本:

1on:
2  push:
3    tags:
4      - 'backend@*'
5name: Deploy backend
6jobs:
7  deploy:
8    name: deploy
9    runs-on: ubuntu-latest
10    steps:
11    - uses: actions/checkout@master
12    - uses: actions/setup-node@master
13      with:
14        node-version: 12.x
15    - run: npm i -g serverless
16    - run: npm i
17      working-directory: ./packages/backend
18    - run: sls deploy --stage prod
19      working-directory: ./packages/backend
20      env:
21        AWS_ACCESS_KEY_ID: ${{ secrets.aws_access_key_id }}
22        AWS_SECRET_ACCESS_KEY: ${{ secrets.aws_secret_access_key }}

定义后台接口

文章分类接口

1{
2  code: 200,
3  data: [
4    { name: 'Serverless', blogCount: 100 },
5    { name: 'Git', blogCount: 50 },
6    { name: 'NodeJS', blogCount: 80 }
7  ]
8}

文章标签接口

1{
2  code: 200,
3  data: [
4    { name: 'Serverless', blogCount: 100 },
5    { name: 'Git', blogCount: 50 },
6    { name: 'NodeJS', blogCount: 80 }
7  ]
8}

文章列表接口

1{
2  code: 200,
3  data: [
4    {
5      id: '9cac989b-d145-4da5-8d3a-22cced623133',
6      extract: '这是一篇关于Serverless的文章,讲述了如何使用Serverless搭建一个博客系统的过程',
7      category: 'Serverless',
8      tags: ['Serverless'],
9      createdAt: '2020-02-29T00:00:00Z',
10      updatedAt: '2020-02-29T00:00:00Z',
11    },
12    {
13      id: 'a03ab935-4d25-469c-b9a9-869a292e8e94',
14      extract: '什么是Redis缓存穿透?有什么危害,我们又应该怎么解决它?',
15      category: 'Redis',
16      tags: ['Redis'],
17      createdAt: '2020-02-28T03:00:00Z',
18      updatedAt: '2020-02-29T10:00:00Z',
19    }
20  ]
21}

单个文章接口

1{
2  code: 200,
3  data: {
4    id: '9cac989b-d145-4da5-8d3a-22cced623133',
5    extract: '这是一篇关于Serverless的文章,讲述了如何使用Serverless搭建一个博客系统的过程',
6    content: '# 这里文章的详情\n###这是第二个段落',
7    category: 'Serverless',
8    tags: 'Serverless|Blog',
9    createdAt: '2020-02-29T00:00:00Z',
10    updatedAt: '2020-02-29T00:00:00Z',
11  }
12}

新增、同步Gist接口

评论列表接口

1    {
2      code: 200,
3      data: [
4        {
5          id: '1c5b2477-3a9c-46ff-b583-99e32a5c0c3f',
6          author: {
7            name: '小明',
8            avatar: 'http://b-ssl.duitang.com/uploads/item/201703/26/20170326161532_aGteC.jpeg'
9          },
10          content: '这篇文章讲得真不错,谢谢分享!',
11          createdAt: '2020-02-29T00:00:00Z',
12          updatedAt: '2020-02-29T00:00:00Z',
13        },
14        {
15          id: 'fd987ffe-29e5-4286-b5f0-f6a3f1ba39bf',
16          author: {
17            name: '小强',
18            avatar: 'http://b-ssl.duitang.com/uploads/item/201412/13/20141213220212_rLVdL.jpeg'
19          },
20          content: '文中第二段有问题,大小写格式不正确',
21          createdAt: '2020-02-28T03:00:00Z',
22          updatedAt: '2020-02-29T10:00:00Z',
23        }
24      ]
25    }

开发前端

添加 less、image支持

1// next.config.js
2const withLess = require('@zeit/next-less')
3const withCSS = require('@zeit/next-css')
4const withImages = require('next-images')
5const nanoid = require('nanoid')
6require('dotenv').config()
7
8module.exports = withImages(withCSS(withLess({
9  /* config options here */
10  target: 'serverless',
11  env: {
12    API: process.env.API,
13    random: nanoid(6)
14  },
15  lessLoaderOptions: {
16    javascriptEnabled: true
17  },
18  webpack: (config, { isServer }) => {
19    if (isServer) {
20      require('ignore-styles')
21      const antStyles = /antd\/.*?\/style\/css.*?/
22      const origExternals = [...config.externals]
23      config.externals = [
24        (context, request, callback) => {
25          if (request.match(antStyles)) return callback()
26          if (typeof origExternals[0] === 'function') {
27            origExternals[0](context, request, callback)
28          } else {
29            callback()
30          }
31        },
32        ...(typeof origExternals[0] === 'function' ? [] : origExternals)
33      ]
34
35      config.module.rules.unshift({
36        test: antStyles,
37        use: 'null-loader'
38      })
39    }
40    return config
41  }
42})))
43

页面缓存处理

Blog大多数时候是静态的,所以想要做缓存。

首页的缓存

首页中不容易加版本号,所以首页中的缓存可以不放,或者放个较短的时间,我为首页设置了5分钟的缓存。

具体方式如下:

1App.getInitialProps = async ({ res }) => {
2  // set cachec-control
3  if (res) {
4    res.setHeader('Cache-Control', 'max-age=300, public') // 5 minutes
5  }
6}
文章页的缓存

文章页主要有两个方面的更新:一是后端返回的Blog内容可能会更新,二是整个网站的主题可能会更新。

Blog内容更新的暂时不做处理,先看网站主题的更新。

因为网站主题每次变更时,会涉及到前端项目的部署,所以就可以在前端部署的时候生成一个版本号,然后在文章的链接中加入这个版本号使其无法命中缓存。

具体做法如下:

首先,在next.config.js中加入process.env.random

1// next.config.js
2const nanoid = require('nanoid')
3require('dotenv').config()
4
5module.exports = {
6  /* config options here */
7  target: 'serverless',
8  env: {
9    API: process.env.API,
10    random: nanoid(6)
11  }
12}

然后,在内部的链接中,加入这个process.env.random作为版本号

1<Link href={{ pathname: `/posts/${post.url}`, query: { random: process.env.random } }}>
2  <a>{post.title}</a>
3</Link>

这样一来,就可以给文章设置一个较长的缓存时间(如一天或一个周)

1Post.getInitialProps = async function ({ res }) {
2  // set cachec-control
3  if (res) {
4    res.setHeader('Cache-Control', 'max-age=86400, public')
5  }
6}

大功告成!

评论系统的设计

评论系统略微复杂一些,在做自己的评论系统之前,我考虑过以下的既有方案

其中Disqus在国内的速度比较慢,广受诟病,不太适合。Gittalk其实是挺不错的,既有较美观的外表,又有不错的速度,但是也有缺点,比如说需要用到 app secret,将app secret放在前端,总归是不安全的吧。

另外还有一个很重要的原因:既然是在用Serverless做博客系统,希望能将评论系统也实现出来。

功能设计

博客的评论系统需要有如下几个核心功能点:

  1. 登录、评论
  2. 引用别人的评论
  3. 当评论被别人引用时,给原作者发邮件提醒

如果只考虑这些因素的话,用Gist本身的评论系统(即将博客中收到的评论发布到Gist的评论中)应该是不错的,既有了数据存储的位置,又有了天然的回复提醒功能(Github Gist中内建支持),而且还能将Gist中的评论同步显示到网站中。

但是Gist本身也有不少的缺点,最致命的是大陆不翻墙无法访问这点。即使Github提醒了用户有人回复,用户可能也无法通过链接正常打开Gist页面,而且我们期望的还是将用户导流回博客更好一些。

至于评论的同步这个功能,考虑到一篇文章会发放到多个平台,如Gist、个人博客、微信公众号、知乎专栏等,评论的割裂是必然存在的,这里将两处的评论同步到一处,并无太大的意义。

因此上,我大致的评论系统实现思路如下:

  1. Serverless后端应用中添加Comments表,并添加与Comment相关的增删改查接口
  2. Serverless后端应用中添加邮件系统,在用户的评论被引用时,给用户发邮件提醒。支持退订
  3. 在前端应用中添加登录功能,使用Github进行登录
  4. 前端应用中添加发表评论相关的组件

再考虑到评论、邮件、验证是很多系统都需要的,可以分别做成微服务。可以先分开做这三个系统,之后再集成到博客中。