使用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,设置成目标域名。

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

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

custom:
  domains:
    prod: api.banyudu.com
    staging: staging-api.banyudu.com
    dev: dev-api.banyudu.com
  customDomain:
    domainName: ${self:custom.domains.${self:provider.environment.stage}} # Change this to your domain.
    basePath: 'blog' # This will be prefixed to all routes
    stage: ${self:provider.environment.stage}
    createRoute53Record: true

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

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

使用Github Actions自动化部署

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

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

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

前端的部署脚本

on:
  push:
    tags:
      - 'frontend@*'
name: Deploy Frontend
jobs:
  deploy:
    name: deploy
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@master
    - uses: actions/setup-node@master
      with:
        node-version: 12.x
    - run: npm i -g serverless
    - run: npm i
      working-directory: ./packages/frontend
    - run: |
        echo AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID >> .env
        echo AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY >> .env
      working-directory: ./packages/frontend
      env:
        AWS_ACCESS_KEY_ID: ${{ secrets.aws_access_key_id }}
        AWS_SECRET_ACCESS_KEY: ${{ secrets.aws_secret_access_key }}
    - run: sls
      working-directory: ./packages/frontend

后端的部署脚本:

on:
  push:
    tags:
      - 'backend@*'
name: Deploy backend
jobs:
  deploy:
    name: deploy
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@master
    - uses: actions/setup-node@master
      with:
        node-version: 12.x
    - run: npm i -g serverless
    - run: npm i
      working-directory: ./packages/backend
    - run: sls deploy --stage prod
      working-directory: ./packages/backend
      env:
        AWS_ACCESS_KEY_ID: ${{ secrets.aws_access_key_id }}
        AWS_SECRET_ACCESS_KEY: ${{ secrets.aws_secret_access_key }}

定义后台接口

文章分类接口

{
  code: 200,
  data: [
    { name: 'Serverless', blogCount: 100 },
    { name: 'Git', blogCount: 50 },
    { name: 'NodeJS', blogCount: 80 }
  ]
}

文章标签接口

{
  code: 200,
  data: [
    { name: 'Serverless', blogCount: 100 },
    { name: 'Git', blogCount: 50 },
    { name: 'NodeJS', blogCount: 80 }
  ]
}

文章列表接口

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

单个文章接口

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

新增、同步Gist接口

评论列表接口

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

开发前端

添加 less、image支持

// next.config.js
const withLess = require('@zeit/next-less')
const withCSS = require('@zeit/next-css')
const withImages = require('next-images')
const nanoid = require('nanoid')
require('dotenv').config()

module.exports = withImages(withCSS(withLess({
  /* config options here */
  target: 'serverless',
  env: {
    API: process.env.API,
    random: nanoid(6)
  },
  lessLoaderOptions: {
    javascriptEnabled: true
  },
  webpack: (config, { isServer }) => {
    if (isServer) {
      require('ignore-styles')
      const antStyles = /antd\/.*?\/style\/css.*?/
      const origExternals = [...config.externals]
      config.externals = [
        (context, request, callback) => {
          if (request.match(antStyles)) return callback()
          if (typeof origExternals[0] === 'function') {
            origExternals[0](context, request, callback)
          } else {
            callback()
          }
        },
        ...(typeof origExternals[0] === 'function' ? [] : origExternals)
      ]

      config.module.rules.unshift({
        test: antStyles,
        use: 'null-loader'
      })
    }
    return config
  }
})))

页面缓存处理

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

首页的缓存

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

具体方式如下:

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

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

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

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

具体做法如下:

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

// next.config.js
const nanoid = require('nanoid')
require('dotenv').config()

module.exports = {
  /* config options here */
  target: 'serverless',
  env: {
    API: process.env.API,
    random: nanoid(6)
  }
}

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

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

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

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

大功告成!

评论系统的设计

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

其中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. 前端应用中添加发表评论相关的组件

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


0条评论