前言
Serverless = Faas (Function as a service) + Baas (Backend as a service)
Serverless 让我们更专注于业务开发, 一些常见的服务端问题, Serverless 都帮我们解决了:
以上前三点是 Faas 的范畴, 第四点是 Baas 的范畴. 简单来讲, Serverless 可以理解为有个系统, 可以上传或在线编辑一个函数, 这个函数的运行环境由系统提供, 来一个请求, 系统就自动启动一个函数进行服务, 我们只需要写函数的代码, 提交后, 系统根据流量自动扩缩容, 而函数里可以调用各种现有的云服务 api 来简化我们的开发与维护成本.
看了很多关于 Serverless 的文章, 大部分都在讲架构, 讲 serverless 本身的实现. 本篇就以简单明了的例子阐述一个简易博客系统在腾讯云 Serverless 中的落地, 期间只会涉及 Faas 和 Baas 的实践部分.
让我们开始吧~
简易博客系统功能概要
时序图
如上时序图所示, 本次实现的简易博客系统, 只有博客列表页和博客内容页, 不涉及评论, 登录, 侧重于 Serverless 落地相关的内容, 如云函数本身怎么编写, 怎么在本地开发, 怎么跟自定义域名关联, 怎么访问云 MySQL, 云函数内的代码, 如 Router, Controller, Service, Model 和 View 等怎么组织.
带着这些疑问, 让我们开始吧~
云函数的初始化与基础配置
访问 https://cloud.tencent.com/product/scf, 点击立即使用进入云函数:
腾讯云函数入口
为了让你不感到畏惧, 先交个底, 腾讯云函数每月有 100 万次调用的免费额度, 个人学习使用完全够了.
好的, 我们继续~
在点击上图的 "立即使用" 后, 我们可以看到云函数的概览界面:
腾讯云函数概览
点击左侧的函数服务, 在出现的界面中, 点击新建:
新建云函数
出现了下方截图的这个页面, 输入函数名, 选择语言, 可以从函数模板中选择一个初始化, 这里选了右下角这个 "国庆 SCF 运营推广活动 Demo". ps, 注意这里有很多模板, 比如访问数据库, 返回页面, 图像压缩, 视频转码, 文件合并等等, 降低了我们的入门成本.
选择模板
选择好模板后, 点击下一步, 出现的这个界面, 设置环境变量和网络环境
设置环境变量与网络环境
点击完成, 我们的云函数就生成啦, 来看一下效果, 虽然是云函数, 但这里不止一个文件哦, 是可以以多个文件的形式组织起来的:
云函数代码
细看代码, 做的事情很简单, 根据云函数标准, 暴露了一个 main_handler, 里边读取了一个 html 页面模板, 通过 render 函数将 html 模板 + 数据解析为 html 字符串, 最后返回.
那我们要怎么才能访问到这个云函数呢?
答案就是配置触发方式了, 我们将触发方式配置成 API 网关触发, 设置如下:
触发器配置
这里解释一些图中的概念:
这里我们选择 API 网关触发, 也就是有请求过来时, 才触发这个函数.
保存后, 我们就能看到云函数的访问路径了:
云函数访问路径
这里贴一下我例子中的访问链接, 大家可以体验一下~
https://service-r3uca4yw-1253736472.gz.apigw.tencentcs.com/release/test
以上就是我们对云函数的初步认识, 接下来我们一步步深入, 带你打造一个简易博客系统
Tencent Serverless Toolkit for VS Code
首先, 我们需要一个本地开发环境, 虽然线上编辑器的体验与 vscode 已经比较相近了, 但毕竟本地代码编辑经过我们配置, 还是更好用的. 那我们在本地修改了代码, 怎么发布云函数呢?
以 VSCode 为例, 我们需要安装 "Tencent Serverless Toolkit for VS Code", 可以在 VSCode 的插件里搜索安装, 插件首页会有详细地安装说明, 这里就不再赘述.
插件界面如图:
scf vscode 插件
安装完后, 左侧会多一个云函数的图标. 通过这个插件, 你可以:
通常前端的代码, 需要打包, 执行 npm install
, npm run build
等, 云端函数没有提供这个环境, 我们可以在本地打包后, 通过这个插件发布代码. 当然, 我们还可以通过持续集成工具, 运行 cli 来发布, 这个就不展开说了.
数据库选择和设计
数据库选择
这里选择的是腾讯云 MySQL 基础版最低配, 一个月才 29 元~. 当然, 自己搭建数据库对外暴露用于学习也是可以的. 不过如果后期要长期使用, 为了方便维护和确保数据稳定, 建议选择云 MySQL
. 云 MySQL 不需要我们关心安装和数据因机器挂了而丢失的问题. 开箱即用也是 Baas 的特点.
注意到里边选择的网络是 Default-VPC, Default-Subnet, 需要保持跟云函数一致, 不然云函数访问不到 MySQL,如图:
腾讯云 MySQL 购买
激活云 MySQL 后, 这里可以看到内网 ip 和端口, 云函数可以通过这个 ip 和端口访问到 MySQL:
腾讯云 MySQL
数据库设计
因为是一个简易的博客系统, 不涉及登录和评论, 在满足数据库设计第三范式的基础上, 我们只需要设计一张表即可, 即博客表本身:
字段名 | 字段类型 |
---|---|
id | 主键 |
title | 标题 |
content | 文章内容 |
createdAt | 创建时间 |
updatedAt | 修改时间 |
因为我们后边会使用 MySQL 的 Node.js ORM 框架 Sequelize 来操作数据库, 数据库表的创建是自动完成的, 这里我们就不再说明啦~
后边会有 Sequelize, 还有怎么连接, 操作数据库的介绍~
云函数自定义域名与 API 网关映射
域名解析
前面说到, 云函数创建完配置好 API 网关触发器后, 就可以在外网访问了, 但是默认的 url 完全无法记忆, 不利于传播, 我们需要一个自定义域名. 关于域名如何购买这里就不展开了, 大家可以参照官方文档进行购买, 便宜的才 5 块钱一年 ~
官方文档:
https://cloud.tencent.com/document/product/242/9595
这里给大家介绍, 怎么给云函数绑定自定义域名:
在购买域名后, 我们需要在域名解析列表里添加域名解析:
添加域名解析
如下图, 将 @ 和 www CNAME 到我们的云函数域名, 相当于是给云函数的域名起了个别名, 访问自定义域名, 就可以访问到云函数域名经过解析后的 ip:
云函数解析细节注意, 记录值只需要填写云函数的域名即可, 不需要填路径, 也不需要填协议
API 网关映射
光是将自定义域名解析到云函数域名是不够的, 我们还要映射路径, 我们打开 API 网关的服务, 点击我们的云函数服务名, 打开自定义域名, 点击新建:
API 网关映射
按照截图中操作后, 我们就可以在外网以自己的域名访问到云函数啦~
这里放上本篇文章最终实现的简易博客地址: https://www.momentfly.com/
云函数中的路由设计
正如我们前面提到的, 实现的简易博客系统有两个页面, 可以通过两个云函数来对应两个页面, 但这种实现不优雅, 因为代码复用不了, 比如我们写的一些处理页面的公共方法, 就得在两个函数里都实现一遍. 而且 node_modules 在两个云函数里都得存在, 浪费空间.
所以我们得在一个函数里, 将两个页面的代码组织起来, 最容易想到的是写一个简单的判断, if 路径为 /, 则返回博客列表页, else if 路径为 /post, 则返回博客内容页. 但这还是不优雅, 要获取路径, 再写一堆 if else 来做路由, 不是很好维护, 而且如果要扩展, 还得增加 get, post 等请求的判断, 再加上路径上的参数也要手工写函数来获取.
能不能像 Express 或 koa 一样方便地组织代码呢? 答案是肯定的!
如果对比过 AWS Lambda (亚马逊云 云函数), 会发现腾讯云函数和 AWS Lambda 在入口参数上是一致的, 我们可以通过 serverless-http 这个库, 实现 koa 的接入. 这个库原本是为 AWS lambda 打造的, 但可以无缝地在腾讯云函数上使用.
如上面提到的, 云函数的入口代码 main_handler 如下:
exports.main_handler = async (event, context, callback) => {
}
我们将代码拉到本地, 安装 koa, koa-router, serverless-http 后, 按照如下方式组织, 即可将 koa 无缝接入:
const Koa = require("koa");
const serverless = require("serverless-http");
const app = new Koa();
const router = require('./router');
app
.use(router.routes())
.use(router.allowedMethods())
const handler = serverless(app);
exports.main_handler = async (event, context, callback) => {
return await handler(
{ ...event, queryStringParameters: event.queryString },
context
);
}
而我们的 router 文件, 就是 koa 常规的写法了:
const Router = require('koa-router');
const { homeController } = require('../controllers/home')
const { postController } = require('../controllers/post')
const router = new Router();
router
.get('/', homeController)
.get('/post', postController)
module.exports = router;
看到这里, 熟悉 koa 的同学已经掌握了该篇的主旨, 明白了 Serverless 落地的一种方式. 但接下来还是会完整地将这个简易博客系统搭建相关的逻辑讲清楚, 感兴趣的同学继续往下看吧~
云函数中的代码组织
和普通 koa 应用的组织方式一致, 为了职责分明, 通常会将代码组织为 Router, Controller, Service, Model, View 等. 在通过 serverless-http
将 koa 接入进来后, 我们的云函数服务组织方式就完全跟传统 koa 应用一致了, 我们来看看项目的完整目录:
/blog
├── controllers
| ├── home
| | └── index.js
| └── post
| └── index.js
├── index.js
├── model
| ├── db.js
| └── index.js
├── package.json
├── router
| └── index.js
├── services
| ├── assets
| | └── index.js
| ├── data
| | └── index.js
| ├── home
| | └── render.js
| ├── post
| | └── render.js
| └── response
| └── index.js
├── template.yaml
├── view
| ├── github-markdown.css
| ├── home
| | ├── home.css
| | └── home.html
| └── post
| ├── post.css
| └── post.html
└── yarn.lock
Controller
Controller 应该清晰地反应一个请求的处理过程, 一些实现细节要封装起来, 放在 Service 中, 这点在流程复杂的项目中特别重要.
我们两个页面的 Controller 就很简单:
controllers/home/index.js - 博客列表页
const render = require('../../services/home/render');
const { getBlogList } = require('../../services/data')
const { htmlResponse } = require('../../services/response')
exports.homeController = async (ctx) => {
const blogList = await getBlogList() // 获取数据
const html = render(blogList) // 数据 + 模板生成 html
htmlResponse(ctx, html) // 返回 html 的流程
}
controllers/post/index.js - 博客内容页
const render = require('../../services/post/render');
const { getBlogById } = require('../../services/data')
const { htmlResponse } = require('../../services/response')
exports.postController = async (ctx) => {
const { id } = ctx.query
const blog = await getBlogById(id)
const html = render(blog)
htmlResponse(ctx, html)
}
可以看到, 我们的 Controller 都只有三个步骤, 即
我们会在接下来的 Services 里讲清楚这三个步骤的具体实现.
Services
本篇的简易博客系统, 博客列表页和内容页很相似, 所以代码也会比较相近, 这里就选择博客列表页来讲 Services 啦:
上边的 Controller 都是先获取数据的, 我们来看看 data 这个 services:
/services/data/index.js
const { Blog } = require('../../model')
exports.getBlogList = async () => {
await Blog.sync({}); // 如果表不存在, 则自动创建, sequelize 的一个特性
return await Blog.findAll();
}
exports.getBlogById = async (blogId) => {
await Blog.sync({});
return await Blog.findOne({
where: {
id: blogId,
}
})
}
通过定义好的 Model, 也就是 Blog, 执行 await Blog.findAll()
, await Blog.findOne
即可获取到博客列表和博客首页.
数据获取完了, 按照上边 Controller 的流程, 我们就要执行数据与 html 模板的拼接了, 来看 render 的 service:
services/home/render.js
const template = require('art-template');
const marked = require('marked');
const hljs = require('highlight.js');
const { markdownCss, hightlightCss, resolveAssetsFromView } = require('../assets');
const homeHtml = resolveAssetsFromView('./home/home.html');
const homeCss = resolveAssetsFromView('./home/home.css');
module.exports = (blogList) => {
marked.setOptions({
highlight: function (code, lang) {
return hljs.highlight(lang, code).value;
}
});
let html = template.render(homeHtml, {
blogList: blogList.map((blog) => {
blog.content = marked(blog.content);
return blog;
}),
markdownCss,
hightlightCss,
homeCss,
})
return html
}
这里用了 art-template, 是一个高性能模板引擎.
使用模板引擎来处理 html 模板和数据, 没有用 react, vue 的原因是简易博客系统太简单, 没必要使用框架. 况且这个简易博客系统的初衷侧重于 Serverless 的实践, 用 react, vue 或者简单的模板引擎, 对 Serverless 实践没有影响, 如果换成 react, vue 做 ssr, 则需要另外开一个话题阐述了.
marked
是将 markdown string 转成 html string 的一个库, 如将 # hello
转成 <h1>hello</h1>
highlight.js
用于高亮 markdown 中的代码markdownCss, hightlightCss, homeCss,
是写好的 css 文件, 用 fs 读取出来的文件内容字符串关键的一句, 通过 art-template, 将 html 模板, 数据 (blogList, css) 渲染成 html
let html = template.render(homeHtml /* 模板 */, { /* 模板变量 */
// 数据
blogList: blogList.map((blog) => {
blog.content = marked(blog.content); // markdown 的处理
return blog;
}),
// 对模板来说, 以下这些也是数据, 只不过数据内容是 css 字符串罢了
markdownCss,
hightlightCss,
homeCss,
});
上面的 markdownCss, hightlightCss, homeCss 是通过 assets 处理出来的, 我们来看一下 assets 的处理:
/services/assets/index.js
const fs = require('fs');
const path = require('path');
const hightlightCss = fs.readFileSync(path.resolve(__dirname, '../../node_modules/highlight.js/styles/atom-one-light.css'), {
encoding: 'utf-8',
})
const markdownCss = fs.readFileSync(path.resolve(__dirname, '../../view/github-markdown.css'), {
encoding: 'utf-8',
})
const resolveAssetsFromView = (relativePath) => {
// 辅助函数, 方便从 view 将文件读取为字符串
const filePath = path.resolve(__dirname, '../../view', relativePath);
console.log(`filePath: ${filePath}`);
return fs.readFileSync(filePath, {
encoding: 'utf-8',
})
}
module.exports = {
hightlightCss,
markdownCss,
resolveAssetsFromView
}
通过 fs.readFileSync(), 按照 utf-8 的方式读取文件, 读取完后连同辅助函数一起暴露出去.
到了 Controller 的最后一步, 即返回 html, 我们通过 response service 来实现:
/services/response/index.js
exports.htmlResponse = (ctx, html) => {
ctx.set('Content-Type', 'text/html');
ctx.body = html
ctx.status = 200
}
Model
上边的 data service, 通过 Blog Model 可以轻易的获取数据, 那 Blog Model 的实现是怎样的呢? 我们来看一下:
/model/index.js
const { Sequelize, sequelize, Model } = require('./db');
class Blog extends Model { }
Blog.init({
title: { // 定义 title 字段
type: Sequelize.STRING, // 字符串类型
allowNull: false // 不允许为空
},
content: {
type: Sequelize.TEXT('medium'), // mysql 的 MEDIUMTEXT
allowNull: false // 不允许为空
}
}, {
sequelize,
modelName: 'blog'
});
module.exports = {
Blog,
}
我们使用 sequelize 这个 ORM 库来简化 MySQL 的操作, 不需要我们手写 SQL 语句, 库本身也帮我们做了 SQL 注入的防御.
Blog.init 初始化了 Blog 这个 Model. id, createdAt, updatedAt 这三个字段不需要我们声明, sequelize 会自动帮我们创建.
来看看 db 的实现
/model/db.js
const Sequelize = require('sequelize');
const sequelize = new Sequelize('blog', 'root', process.env.password, {
host: '172.16.0.15',
dialect: 'mysql'
});
const Model = Sequelize.Model;
module.exports = {
Sequelize,
sequelize,
Model,
}
blog 是数据库的名称, root 是登录的账户, 密码存放在环境变量中, 通过 process.env.password
获取, 也就是前边我们在云函数创建时, 填写的环境变量.
View
这里的 view 层只是 css 和 html 模板, css 就不讲了, 这里来看一下 art-template 的模板:
/view/home/home.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>serverless blog demo</title>
<style>
{{markdownCss}}
{{hightlightCss}}
{{homeCss}}
</style>
</head>
<body>
<div class="blog-home">
{{each blogList}}
<div class="post" onclick="location.href='./post?id={{$value.id}}'">
<h1>{{$value.title}}</h2>
<div class="markdown-body">{{@ $value.content}}</div>
</div>
{{/each}}
</div>
</body>
</html>
{{}}
里是模板变量, 前边 render 方法的第二个参数里的字段, 就能从 {{}}
中取到.
以上就是我们简易博客系统的代码逻辑, 目前只有两个页面的代码, 如果要增加博客创建页面, 流程是一致的, 增加相关的 Router, Controller, Service 即可. 目前笔者是通过腾讯云的数据库操作界面直接写的数据~.
如果要增加评论功能, 我们需要新增一个表来存储了, 当然后期你可以按照自己的意愿扩展~
实现效果
https://www.momentfly.com/
博客列表页
小结
通过搭建简易博客系统, 我们了解了 Serverless 的一种实践. 期间涉及了如何创建云函数, 介绍了本地 VSCode 云函数插件, 云函数自定义域名与 API 网关映射, 云数据库的创建与连接, 云函数的代码组织方式等. 整个过程都很轻量, 没有太多涉及服务端运维的内容. Serverless 的兴起, 会给我们的开发带来很大的便利. 后期各大云服务商也必将完善 Serverless 的服务, 带来更佳的 DevOps 体验.
最后,让我们一起拥抱 Serverless ,动手实战吧~
完整Demo获取
Github地址: https://github.com/LuckyWinty/serverless
点击阅读原文即可跳转最后