使用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 https://help.disqus.com/en/
- 使用Gittalk https://github.com/gitalk/gitalk
其中Disqus在国内的速度比较慢,广受诟病,不太适合。Gittalk其实是挺不错的,既有较美观的外表,又有不错的速度,但是也有缺点,比如说需要用到 app secret,将app secret放在前端,总归是不安全的吧。
另外还有一个很重要的原因:既然是在用Serverless做博客系统,希望能将评论系统也实现出来。
功能设计
博客的评论系统需要有如下几个核心功能点:
- 登录、评论
- 引用别人的评论
- 当评论被别人引用时,给原作者发邮件提醒
如果只考虑这些因素的话,用Gist本身的评论系统(即将博客中收到的评论发布到Gist的评论中)应该是不错的,既有了数据存储的位置,又有了天然的回复提醒功能(Github Gist中内建支持),而且还能将Gist中的评论同步显示到网站中。
但是Gist本身也有不少的缺点,最致命的是大陆不翻墙无法访问这点。即使Github提醒了用户有人回复,用户可能也无法通过链接正常打开Gist页面,而且我们期望的还是将用户导流回博客更好一些。
至于评论的同步这个功能,考虑到一篇文章会发放到多个平台,如Gist、个人博客、微信公众号、知乎专栏等,评论的割裂是必然存在的,这里将两处的评论同步到一处,并无太大的意义。
因此上,我大致的评论系统实现思路如下:
- Serverless后端应用中添加Comments表,并添加与Comment相关的增删改查接口
- Serverless后端应用中添加邮件系统,在用户的评论被引用时,给用户发邮件提醒。支持退订
- 在前端应用中添加登录功能,使用Github进行登录
- 前端应用中添加发表评论相关的组件
再考虑到评论、邮件、验证是很多系统都需要的,可以分别做成微服务。可以先分开做这三个系统,之后再集成到博客中。