初探cloudbase+云函数/云托管

1,869 阅读4分钟

1311631169644_.pic.jpg

桃李春风一杯酒,江湖夜雨十年灯。

前言

在使用云开发cloudbase的时候,脑海中总是时不时冒出:如何在使用云开发的时候将调用数据库等后端操作和前端业务分离开?

在阅读了「CloudBase CMS」的源码,我萌生出了使用nestjs+cloudbase+云函数(没有写错,最开始的想法就是云函数)的想法。

nestjs 是什么?

image.png

文档是这样介绍它的:

Nest 是一个用于构建高效,可扩展的 Node.js 服务器端应用程序的框架。它使用渐进式 JavaScript,内置并完全支持 TypeScript(但仍然允许开发人员使用纯 JavaScript 编写代码)并结合了 OOP(面向对象编程),FP(函数式编程)和 FRP(函数式响应编程)的元素。 在底层,Nest使用强大的 HTTP Server 框架,如 Express(默认)和 Fastify。Nest 在这些框架之上提供了一定程度的抽象,同时也将其 API 直接暴露给开发人员。这样可以轻松使用每个平台的无数第三方模块。

cloudbase又是什么?

云开发(Tencent CloudBase,TCB)是腾讯云提供的云原生一体化开发环境和工具平台,为开发者提供高可用、自动弹性扩缩的后端云服务,包含计算、存储、托管等 serverless 化能力,可用于云端一体化开发多种端应用(小程序、公众号、Web 应用、Flutter 客户端等),帮助开发者统一构建和管理后端服务和云资源,避免了应用开发过程中繁琐的服务器搭建及运维,开发者可以专注于业务逻辑的实现,开发门槛更低,效率更高。

怎么还有个云托管?

云托管(Tencent CloudBase Run)是云开发(Tencent CloudBase,TCB)提供的新一代云原生应用引擎(App Engine 2.0),支持托管用任意语言和框架编写的容器化应用。和云开发其他产品(云函数、云数据库、云存储、扩展应用、HTTP 访问服务、静态网站托管等)一起为用户提供云原生一体化开发环境和工具平台,为开发者提供高可用、自动弹性扩缩的后端云服务,可用于云端一体化开发多种端应用(小程序、公众号、Web 应用、微服务应用、Flutter 客户端等),避免应用开发过程中繁琐的服务器搭建及运维,使开发者可以专注于业务逻辑的实现,开发门槛更低,效率更高。

基于nestjs进行项目的搭建

因为cloudbase提供了nestjs的框架,因此我们可以直接使用cloudbase的脚手架工具进行搭建,此处默认我们是拥有云开发环境,并且已经在腾讯云控制台配置好云开发环境。

脚手架创建项目

  • 安装脚手架工具
npm i -g @cloudbase/cli
  • 测试安装是否成功 cloudbase 脚手架安装成功后,我们可以使用cloudbase -v去查看是否安装成功,当然, 为了方便输入,cloudbase也可以简写为tcb
tcb -v
  • 登陆cloudbase
cloudbase login
  • 本地创建项目
tcb new <appName> [template] 
# 比如 
tcb new nest_test nest-starter

项目文件

文件作用
app.controller.ts带有单个路由的基本控制器示例。
app.controller.spec.ts对于基本控制器的单元测试样例
app.module.ts应用程序的根模块。
app.service.ts带有单个方法的基本服务
main.ts应用程序入口文件。它使用 NestFactory 用来创建 Nest 应用实例。

云函数构建

在最初的时候,项目是以云函数的形式进行部署的,那么就必须配置cloudbaserc.json文件:

{
    "version": "2.0",
    "envId": "环境ID",
    "$schema": "https://framework-1258016615.tcloudbaseapp.com/schema/latest.json",
    "framework": {
      "name": "函数名",
      "plugins": {
        "node": {
          "use": "@cloudbase/framework-plugin-node",
          "inputs": {
            "name": "函数名",
            "path": "/函数路径",
            "entry": "app.js",
            // 配置函数的build命令
            "buildCommand": "npm install --prefer-offline --no-audit --progress=false && npm run build",
            "functionOptions": {
              "timeout": 5,
              // 环境变量
              "envVariables": {
                "NODE_ENV": "production",
                "TCB_ENV_ID": "{{env.TCB_ENV_ID}}",
              }
            }
          }
        }
      }
    }
  }
  

cloudbaserc.json文件会自动识别.env.local.env中的环境变量,因此我们可以以花括号的形式动态引入配置的环境变量,同时,为了防止敏感信息的上传,我们一般还会在.gitignore文件中配置:禁止.env.local.env上传到仓库中。

# compiled output
/dist
/node_modules
.env.local

# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*

# OS
.DS_Store

# Tests
/coverage
/.nyc_output

# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace

# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json

云函数的上传难度属于简单,因此当我们配置完成并上传之后,我们便可以在项目中通过app.callFunction()的形式进行使用了。

云函数遇到的问题

因为我需要提供一个抠图的API接口,然而我将base64图片进行上传的时候报了个错:EXCEED_MAX_PAYLOAD_SIZE查询文档」得知:

HTTP 请求体超出最大体积限制(文本 100 KB,二进制 20MB)

最开始的想法

最开始的想法是我们在项目中设置body的限制体积,从而解决这个问题,但是设置之后发现并不能直接改变http请求体的大小,通过在腾讯云控制台中查询日志发现,每次的请求并不能进入到实际代码中,因此我们无法针对请求体进行大小的配置。

通过联系cloudbase开发同学得知:云函数就是有包体限制的,并且提供了两种解决方案:

  1. 通过@cloudbae/js-sdk的客户端包进行base64的图片上传,之后通过fileId传递给后端进行人体分析,但是我们并不打算存储用户上传上来的图片,因此这个方案暂时被排除

  2. 使用云托管的形式进行部署,将服务挂载起来,这样可以设置请求题大小。

通比对,因此我决定采用云托管的形式进行服务部署。

云函数和云托管的区别

模块云函数云托管
请求并发单实例单并发,多并发时需要拉起多个实例处理单实例多并发
语言/框架开发语言和框架支持有限兼容已有框架
问题定位容易定位相对灵活,依赖自定义
常驻运行不支持支持
日志监控基于函数基于服务
版本灰度支持按流量灰度支持按流量灰度、按 URL 参数灰度
弹性扩缩容支持支持
对外服务提供默认 URL 和 SDK提供默认 URL
跨平台函数规则不同,很难跨平台部署可跨平台部署
私有部署不支持可迁移私有化/混合部署
上手难度简单中等
计费方式按请求量计费、按请求次数和每次调用产生的 GBS按容器运行消耗的 CPU、内存、服务产生的外网出流量、服务构建时长

云托管构建

云托管的定义在上面已经讲过了。此处便不再过多赘述。感兴趣的同学可以查阅「云托管文档」。

如何快速构建一个云托管Nodejs应用?

应用容器化

在项目根目录下,创建一个名为 Dockerfile 的文件,内容如下:

# 使用官方 Node.js 12 轻量级镜像.
# https://hub.docker.com/_/node
FROM node:12-slim

# 定义工作目录
WORKDIR /usr/src/app

# 将依赖定义文件拷贝到工作目录下
COPY package*.json ./

# 以 production 形式安装依赖
RUN npm install --only=production

# 将本地代码复制到工作目录内
COPY . ./

# 启动服务
CMD [ "node", "index.js" ]

那么,如何构建一个云托管的Nestjs项目?

根据上述dockerfile的文件,我们可以做出相关配置:

# 使用官方Node.js 12 轻量级镜像
FROM node:12.15.0-alpine

# 设置Alpine linux系统时区
RUN apk --update add tzdata \
  && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
  && echo "Asia/Shanghai" > /etc/timezone \
  && apk del 

RUN mkdir -p /usr/src/app

# 定义工作目录
WORKDIR /usr/src/app

# 将依赖定义文件拷贝到工作目录下
COPY package.json /usr/src/app/package.json
# 将依赖定义文件拷贝到工作目录下
COPY package*.json ./

# 将本地代码复制到工作目录内
COPY . ./
# 以 production 形式安装依赖
RUN npm install

# RUN npm run build

# 端口号
EXPOSE 5000

# 启动服务
CMD npm start

添加一个 .dockerignore 文件,以从容器映像中排除文件:

.git
dist
node_modules
npm-debug.log

此时按照文档,我们可以通过docker本地构建镜像文件上传。

那么,我们如何通过命令行直接一键部署项目呢?

通过cloudbaserc.json配置云托管

cloudbase方面提供了@cloudbase/framework-plugin-container插件供我们做云托管的上传文件,因此,根据「cloudbaserc.json文档」我们可以做如下配置:

{
  "version": "2.0",
  // 环境ID
  "envId": "{{env.TCB_ENV_ID}}",
  "$schema": "https://framework-1258016615.tcloudbaseapp.com/schema/latest.json",
  "framework": {
    "name": "名称",
    "plugins": {
      "service": {
        "use": "@cloudbase/framework-plugin-container",
        "inputs": {
          "serviceName": "serviceName",
          "servicePath": "/serviceName-container",
          "localPath": ".",
          "cpu": 0.25,
          "mem": 0.5,
          // 端口号
          "containerPort": 5000,
          "bumpVersion": true,
          "dockerfilePath": "./Dockerfile",
          "buildDir": "./",
          "uploadType": "package",
          // 环境变量
          "envVariables": {
            "NODE_ENV": "production",
            "TCB_ENV_ID": "{{env.TCB_ENV_ID}}",
            "SECRETID": "{{env.SECRETID}}",
            "SECRETKEY": "{{env.SECRETKEY}}",
            "BAI_DU_APPID": "{{env.BAI_DU_APPID}}",
            "BAI_DU_APP_KEY": "{{env.BAI_DU_APP_KEY}}",
            "BAI_DU_APP_SECRET_KEY": "{{env.BAI_DU_APP_SECRET_KEY}}",
            "COS_SECRET_ID": "{{env.COS_SECRET_ID}}",
            "COS_SECRET_KEY": "{{env.COS_SECRET_KEY}}"
          }
        }
      }
    }
  }
}

这就大功告成?

通过在构建使用云托管的方式上传的时候,我们发现部署会一直失败,经控制台查询构建日志,我们得知是启动命令出错,因此,我们重新配置了npm start命令:

image.png

增加了HOSTING环境变量,用来判断是否是云托管方式,在项目文件中,我们修改了之前判断云函数与本地开发的方式:

原来的方式:

// 兼容云函数与本地开发
if (process.env.NODE_ENV === 'development') {
  await app.listen(port);
} else {
  await app.init();
}
  
// 开发模式下启动开发
if (process.env.NODE_ENV === 'development') {
  bootstrap().then(() => {
    console.log(`App listen on http://localhost:${port}`);
  });
}

现在的方式:

// 兼容云函数与本地开发
if (isRunInServerModel()) {
  await app.listen(port);
} else {
  await app.init();
}

isRunInServerModel() 函数:

// 以服务器模式运行,即通过监听端口的方式运行
export const isRunInServerModel = () =>
  process.env.NODE_ENV === 'development' ||
  process.env.HOSTING;

配置完成,我们重新进行了上传。

算是大功告成吧

哎嘿,上传成功!,同时,查询控制台,我们之前配置的环境变量也全部成功,那此时我不得试一试我的接口啊!!

image.png

但是,我们偶尔会出现 内部服务器报错的问题,但是我们无法查询到具体报错行号、内容,这是我们不能忍受的,因此,我们需要对我们的项目进行完美的配置!!!

项目配置

在初始化构建项目之后,我们得到了一个简易版的nestjs+cloudbase的 demo 服务。

code.png

等等!!!这就完了??? 这么简单?

如果我们只是跑一个demo,试一试云开发,那么,就是这么简单!

但我们在业务开发的时候肯定会进行更多的项目配置以适应我们的业务需求,例如我们会过滤处理 HTTP 异常、参数校验、打印日志等等。

因此,我们还需要对项目进行更多的项目配置才可以。

请求body的限制

nestjs的底层还是使用了express,而nodejs对body的限制在100kb,如果我们传参为base64的图片,则很有可能出现传参失败的情况

{
    code: 500,
    msg: "Service Error: PayloadTooLargeError: request entity too large"
}

因此,我们需要在代码中配置body大小:

一般常用的是bodyParser.json(),但此时typescript会告诉我们:bodyParser已经被弃用。

image.png

所以,我们采用express.json()的形式去配置:

image.png

参数的校验

nestjs提供了ValidationPipe供我们做参数的校验。

  // 参数校验
  app.useGlobalPipes(new ValidationPipe());

首先:我们定义一个validation.pipe.ts文件

import { ArgumentMetadata, Injectable, PipeTransform, BadRequestException } from '@nestjs/common';
import { validate } from 'class-validator';
import { plainToClass } from 'class-transformer';

@Injectable()
export class ValidationPipe implements PipeTransform {
  // transform(value: any, metadata: ArgumentMetadata) {
  //   return value;
  // }
  async transform(value: any, { metatype }: ArgumentMetadata) {
    console.log(`value:`, value, 'metatype: ', metatype);
    if (!metatype || !this.toValidate(metatype)) {
      // 如果没有传入验证规则,则不验证,直接返回数据
      return value;
    }

    // 将对象转换为 Class 来验证
    const object = plainToClass(metatype, value);
    const errors = await validate(object);
    if (errors.length > 0) {
      const msg = Object.values(errors[0].constraints)[0]; // 只需要取第一个错误信息并返回即可
      // Logger.error(`Validation failed: ${msg}`);
      throw new BadRequestException(`Validation failed: ${msg}`);
    }
    return value;
  }

  private toValidate(metatype: any): boolean {
    const types: any[] = [String, Boolean, Number, Array, Object];
    return !types.includes(metatype);
  }
}

同时,根据xxx.dto.ts文件,我们可以设置入参形式

/*
 * @Author: Lupoy
 * @Date: 2021-08-31 11:21:17
 * @LastEditTime: 2021-09-08 11:06:55
 * @LastEditors: Lupoy
 * @Description: DTO文件
 * @FilePath: src/modules/xxx/xxx.dto.ts
 */

import { IsNotEmpty, IsString } from 'class-validator';
import DefaultData from '@/modules/baidu/baidu.i18n';

export class xxxDTO {
  @IsNotEmpty({ message: DefaultData.IS_NOT_EMPTY })
  @IsString({ message: DefaultData.IS_STRING })
  readonly base64: string;
}

export class xxxDTO {
  @IsNotEmpty({ message: DefaultData.IS_NOT_EMPTY_IN_KOU_TU })
  readonly base64: string;
  @IsNotEmpty({ message: DefaultData.IS_NOT_EMPTY_IN_KOU_TU })
  readonly beautify: number;
  @IsNotEmpty({ message: DefaultData.IS_NOT_EMPTY_IN_KOU_TU })
  readonly builder: number;
}

此时就可以在controller中使用:

/*
 * @Author: elizhai
 * @Date: 2021-08-26 14:51:33
 * @LastEditTime: 2021-09-08 11:08:18
 */
import { Body, Controller, Post } from '@nestjs/common';
import { xxxService } from '@/modules/xxx/xxx.service';
import { xxxDTO, xxxDTO } from '@/modules/xxx/baidu.dto';

@Controller('x')
export class xxxController {
  constructor(private readonly xxxService: xxxService) {}

  @Post('bg/xxx')
  removeBg(@Body() body: xxxDTO) {
    return this.xxxService.removeBg(body.base64);
  }
  // 人体分析
  @Post('xxx')
  xxx(@Body() body: xxxDTO) {
    const { base64, beautify, builder } = body;
    return this.xxxService.koutu({ data: base64, beautify, builder });
  }
}

当传递参数不符合我们定义的规范的时候,就会直接拦截报错。

搭建日志系统

此处建议学习「布拉德特皮」的「Nest.js 从零到壹系列(四):使用中间件、拦截器、过滤器打造日志系统

其他配置

nestjs提供了很多API供我们去设置,例如:app.setGlobalPrefix('全局路由前缀')app.disable('x-powered-by')等等等等......

这些我们都可以跟据项目需求进行自定义配置。

如何自由切换云函数和云托管模式?

如何自由切换云函数和云托管两种上传模式?

image.png

我们在根目录定义scripts文件夹,文件夹中存放云函数和云托管两种json文件,然后通过运行脚本命令去写入到根目录的cloudbaserc.json文件夹中。

image.png

/*
 * @Author: elizhai
 * @Date: 2021-09-08 10:42:50
 * @LastEditTime: 2021-09-08 10:57:54
 * @LastEditors: Please set LastEditors
 * @Description: node脚本,执行云函数上传或云托管上传
 * @FilePath: /scripts/index.js
 */

const fs = require('fs');
const path = require('path');

const fileName =
  process.env.FILE_ENV && process.env.FILE_ENV === 'hosting'
    ? 'cloudbaserc'
    : 'cloudbaserc-fx';

fs.readFile(path.join(__dirname, `./${fileName}.json`), 'utf8', function(
  err,
  data,
) {
  if (err) throw err;

  fs.writeFile(path.join(__dirname, '../cloudbaserc.json'), data, 'utf8', err => {
    if (err) throw err;
    console.log('done');
  });
});

结语

到此,我们成功部署服务,并配置了自己项目,给基于cloudbase+云函数 or 云托管 + nestjs 的基础项目就完成了。

过去因为需要配置服务器的原因,前端和后端有着天堑的鸿沟,而 cloudbase 提供的云函数和云托管在一定方面上让前端也可以做后端的项目,可以让一个前端快速成为一个“伪全栈”。

海纳百川,不是因为海大,是因为海的姿态低