分类 后端 下的文章

请输入图片描述

原文链接-因卓诶-人类高质量男性开发的基于TS且自带运行时校验的unicloud云函数是什么样子的?

前言

去年年底在上一家团队的伦哥和我提到了TypeScript的运行时校验,当时由于没有使用TS开发过后端,所以也就不太关心这个事情。但是今年开发TS后端,尤其是最近重构剑指题解开源项目的v
版本接口的时候,才觉得我必须要上运行时检测了。这篇文章内容不会很长(由于时间紧迫,我就简明扼要的写),我会从现有的云函数开始,然后和大家一步一步复盘改造细节。

改造开始

改造之前你需要了解为什么需要运行时检测?目前运行时检测有哪些方案,他们分别有什么弊端?
Nodejs开发中和我们客户端js开发都有共同问题,就是“undefined”,报各种定义找不到,而且后端规定API参数的时候很头疼。
往往要写这样繁琐的代码进行类型安全的控制:

// 添加文章
  async addArticle() {
    return handleMustRequireParam(
      [
        {
          key: 'title',
          value: '文章标题'
        },
        {
          key: 'content',
          value: 'content内容'
        },
        {
          key: 'tagID',
          value: '标签内容'
        },
        {
          key: 'desc',
          value: '描述'
        }
      ],
      this.event.params
    )
      .then(async () => await this.handler('addArticle'))
      .catch((err) => err);
  }

handleMustRequireParam这个方法是定义的一个函数,用于检测必传参数是否未传递或者为空。在每一个云函数中我们都要写这样一大段非常臃肿的代码。而且客户端鬼知道会给你传递什么乱七八糟的参数(这种情况在unicloud 文档云数据库经常出现)。所以swrod开源项目升级了explain.js到v2版本,加入了动态类型校验的中间件用来解决这个问题。

目前的运行检测方案你可以看一下这篇文章,ts运行时检测
看完之后,其实最大的弊端就是它不支持复杂类型校验,我如果想要写如下的代码,以前的方案就不支持我们这样做

interface Test{
  code: string;
  flag: string;
}

type IApiRes = Pick<Test, 'code' | 'flag'>

类似Pick, Partial这样高级的TS泛型在开发中经常用,但是现在很少有一个库能够解决这样的问题。
综合上述,我们使用了tsrpc框架中的核心库: tsbuffer-validator

这个库目前是没有文档的,这次复盘所有的代码都是看test测试用例推出来的,感谢作者大大持续帮我解决开发中各种问题

如果是经常关注我博客的水友都知道,我在前段时间写了一个垃圾的教程专门用来入门tsrpc框架,戳这里去看,tsrpc中最亮眼的ts运行时检测功能就是tsbuffer-validator这个库提供功能。

在浏览了这个库的test用例之后,我这样去开发:

1. 使用tsbuffer-proto-generator这个库将proto转换成schemas.json
2. 添加一个模块去做校验功能,模块中参数就是生成出来的schemas.json和当前触发api路由

首先我们定义proto,去写一个简单的ts代码

// proto/question.ts

export interface AddQuestion {
  title: string;
  areaID: string;
  content: string;
  tagID: string[];
}

我们定义了一个[添加题目]的api请求类型,而实际上我们在云函数路由中是question/addQuestion,我们到时候在写中间件的时候,要记得做一下第一个字母大写的处理,否则会找不到我们写的这个请求类型。

然后我们开始生成schemas.json

npm i tsbuffer-proto-generator --save-dev

这个模块建议安装到云函数之外,即你的工程最外面,不要安装在云函数内部

然后我们在云函数根目录去新建一个schemas文件夹

// schemas/genSchemas.js

const { TSBufferProtoGenerator } = require('tsbuffer-proto-generator');
const glob = require('glob');
const path = require('path');
const fs = require('fs');

async function main() {
  let generator = new TSBufferProtoGenerator({
    baseDir: path.resolve(__dirname, '..', 'proto')
  });

  let files = glob.sync('**/*.ts', {
    cwd: path.resolve(__dirname, '..', 'proto')
  });
  console.log('Files: ', files);

  let result = await generator.generate(files);

  fs.writeFileSync(path.resolve(__dirname, 'schemas.json'), JSON.stringify(result, null, 2));
}
main();

轮到我们的nodemon工具人登场,我们利用nodemon去监听proto文件夹协议的更改,从而触发genSchemas.js。

// nodemon.proto.json

{
  "watch": ["proto"],
  "ext": "ts",
  "exec": "node schemas/genSchemas.js",
  "legacyWatch": true
}

package.json -> script

"proto": "nodemon --config nodemon.proto.json",

运行npm run proto之后就可以看到schemas文件夹中有一个schemas.json

{
  "question/AddQuestion": {
    "type": "Interface",
    "properties": [
      {
        "id": 0,
        "name": "title",
        "type": {
          "type": "String"
        }
      },
      {
        "id": 1,
        "name": "areaID",
        "type": {
          "type": "String"
        }
      },
      {
        "id": 2,
        "name": "content",
        "type": {
          "type": "String"
        }
      },
      {
        "id": 3,
        "name": "tagID",
        "type": {
          "type": "Array",
          "elementType": {
            "type": "String"
          }
        }
      }
    ]
  }
}

细心的你可能会发现对象中有一个key为 “question/AddQuestion”,我们等会写中间件的时候,就拿api的url去匹配这个key从而拿到api的schema

写一个名为tsbuffer-params-validate的中间件

const { TSBufferValidator } = require('tsbuffer-validator');

module.exports = function (event) {
  // schemas 由ts的proto生成的schemas
  // params 参数
  // service 路由url中的service
  // action 具体的方法
  const { schemas, params, service, action } = event;
  const validator = new TSBufferValidator(schemas);
  let vRes = validator.validate(params, `${service}/${action.replace(/^\S/, (s) => s.toUpperCase())}`);
  return vRes;
};

然后中间件会返回给我们结果:这个参数是否校验成功(如果出错误,会返回具体错误信息),然后我们就去云函数中去使用这个中间件。

    // 添加校验参数中间件
      app.use(async ({ event }) => {
        const validateResult = await ParamsValidate({
          ...event,
          params: event.data,
          schemas
        });
        if (!validateResult.isSucc) {
          // 将响应信息改为异常信息
          explain.response.body = {
            message: validateResult.error
          };
        }
      });

到此为止我们都已经改造好了,我们可以测试一下访问云函数具体路由的时候,会不会去校验参数:

"data": {
    "title": "",
    "areaID": "",
    "content": "",
    "tagID": [1]
},

如果我们传递一个这样的参数,中间件就会拦截,并且爆出这样的错误信息:

{
    "isSucc": false,
    "error": {
        "type": "TypeError",
        "params": ["string", "number"],
        "inner": {
            "property": ["tagID", "0"],
            "value": 1,
            "schema": {
                "type": "String"
            }
        }
    }
}

提示我们的tagID需要是string[],而不是number[]。

结束

这篇文章内容很简单,由于很多库没有文档,我还是花了一部分时间去推敲研究的。而且用tsrpc核心库的人太少了,这么好的东西必须要分享给大家,而且在unicloud领域,使用ts开发的人本来就是稀有,所以这篇文章的种种技巧都能实打实的解决各位云函数开发者的大问题,希望大家能用到工程上,用完就懂运行时检测类型有多香了。

前言

某个普通的一天的早晨,水友群的小姐姐和我聊前端架构,因为她们组最近要筹备一些新项目,在做架构的中途出现了很多问题,所以我拿到了她们的架构项目脚手架代码。拿到代码之后我发现深圳那边的前端团队普遍做的很好,有先进的架构思想,也把ts用的很纯粹,最后没帮人家解决问题,反倒是自己学到了不少。最后我们聊到了前后端全栈开发,如何动态校验协议参数等问题,因为熟悉我开源项目(剑指题解)的朋友都知道,我的后端代码尤其是动态校验那块写的是真差,为了ts而用ts,这也是目前很多用ts的小伙伴的通病,所以我一直打算重构我的一部分后端代码,这个时候见多识广的小姐姐就推荐给我了一个框架,这个框架也是[see how]系列第一篇教程的主角,这个框架就叫做TSRPC

关于专栏

关于see how是什么,说来很巧,这也是TSRPC作者王大大对我的seho这个名字的猜测,其实我的一个名字也没那么多深意,然后被大佬解读成了see how,所以我感觉这是一个不错的idea,那么本来就是想要出一个tsrpc的系列教程,和大家一起学习这个优秀的框架,就正好作为see how 专栏的第一篇文章吧。

关于TSRPC

在正文开始之前,我希望大家可以去自行先去简单快速的浏览相关知识,tsrpc是一个ts的开源rpc框架,它是为了全栈项目而生的,从我上手的第一天开始,我就对这个框架有了以下的第一印象:

  1. 天然二进制传输
  2. 纯粹的ts,规避了极大部分开发中的错误
  3. 强大的运行时复杂检测
  4. 这种前后端开发模式,我闻所未闻

官方文档
视频教程

前期准备

学习tsrpc需要你有一些前置知识和其他准备:

  1. 熟悉typescript基本语法
  2. 准备一个mongodb数据库

开发

使用tsrpc开发全栈应用简单到没朋友,可以从官方提供的cli快速创建前后端一体项目:

npx create-tsrpc-app@latest

按照指引选择浏览器应用,等待完成安装之后,你的目录中会出现2个目录:

- backend 后端
- frontend 前端

我们直接一睹为快,在前端项目根目录运行

npm run dev

官方的脚手架为我们准备了一个简单的todolist应用

WX20210706-072702@2x.png

整个前后端的目录结构(摘抄官网)

|- backend --------------------------- 后端项目
    |- src
        |- shared -------------------- 前后端共享代码(同步至前端)
            |- protocols ------------- 协议定义
        |- api ----------------------- API 实现
        index.ts

|- frontend -------------------------- 前端项目
    |- src
        |- shared -------------------- 前后端共享代码(只读)
            |- protocols
        |- index.ts

诶,你可能会疑问了,为啥会有一个莫名其妙的shared目录,还要给前端项目去分享这个目录。是因为在shared这个目录我们要定义协议,啥玩意是协议呢?我们通过一个小小的接口来给大家解释什么是协议;

// PtlAddPost.ts

export interface ReqAddPost {
  newPost: {
     name: string;
  };
}

export interface ResAddPost {
  insertedId: string;
}

我们可以在shared/protocols中新建了一个文件PtlAddPost.ts,我们必须以Ptl进行开头定义协议,协议是用来描述一个接口的请求和响应的结构体的文件,你可以这么理解。协议文件通过shared目录共享到前端,你知道会发生什么事情吗?造成了我们前端在对接口的时候,全程代码提示以及严格和请求和返回类型校验。

那么我们接着后端继续聊,协议定义之后该如何做呢?

npm run proto 每当协议更改后,需要重新运行这个命令

tsrpc的设计是协议和api分离,我们必须要清楚,api在我的认知里就是一个异步函数,tsrpc可以帮助我们根据我们刚刚写的协议生成api,比如刚刚我们实现的PtlAddPost.ts,我们运行

npm run api 新协议生成一个新的api

在api目录中会多出一个ApiAddPost.ts

import { ApiCall } from "tsrpc";
import { ReqAddPost, ResAddPost } from "../shared/protocols/PtlAddPost";

export async function ApiAddPost(call: ApiCall<ReqAddPost, ResAddPost>) {
  
}

我们通过call这个方法获取请求参数以及响应给客户端一些信息,我们来一个简单的例子:

export async function ApiAddPost(call: ApiCall<ReqAddPost, ResAddPost>) {
  if(call.req.newpost.name){
     call.success({
         msg: "hello," + call.req.newpost.name
     })
  }else{
     call.error('Invalid name');
  }
}

我们的第一个api已经写完了,我们需要正常的过一次test,然后我们在让前端去调用。

tsrpc使用的是mocha这个测试框架。

// /test/api/**.test.ts

import { HttpClient } from "tsrpc";
import { serviceProto } from "../../src/shared/protocols/serviceProto";

// 1. EXECUTE `npm run dev` TO START A LOCAL DEV SERVER
// 2. EXECUTE `npm test` TO START UNIT TEST

describe("api 测试", async function () {
  let client = new HttpClient(serviceProto, {
    server: "http://127.0.0.1:3000",
    logger: console,
  });
  let ret = await client.callApi("AddTest", {
    newPost: {
      name: "seho"
    },
  });
});

这是我们后端的一个简单的测试用例,在运行这个测试用例之前,您必须要开启后端的服务:

npm run dev

然后可以再开启一个窗口运行npm run test,如果一切正常,你可以看到下面的控制台输出:

1625806333932.jpg

粗略计算了一下,我们从开始定义协议到api测试完成,一个简单的接口不到5分钟就已经完成。

这个时候我们可以把这个接口放到前端再继续测试一下。

当然在此之前,我们需要运行以下命令:

npm run sync

我们之前提到过,前后端有一个共享的目录,运行此命令我们就可以把协议等信息同步过来,这个时候我们可以在前端的index.ts文件中,可以获得非常完善的代码提示。

// frontend/src/index.ts

import { HttpClient } from "tsrpc-browser";
import { serviceProto } from "./shared/protocols/serviceProto";

// This is a demo code file
// Feel free to modify or clear it

// Create Client
let client = new HttpClient(serviceProto, {
  server: "http://127.0.0.1:3000",
  logger: console,
});

client.callApi("AddTest", {
  newPost: {
    name: "hello, seho"
  },
});

当我们回到浏览器前端页面上时,这个请求就会发出,如果你仔细观察控制台,会看到以下的场景:

1626042788250.jpg

我们的请求体被二进制序列化了,这也是tsrpc的特点之一,我们会在稍后的段落中对tsrpc各个特性做介绍,但是此时此刻我们已经完成了一个api的后端开发->test测试->前端调用。

完善我们的程序

上一个部分相信大家已经学会了如何使用tsrpc开发第一个api,这个部分结合了tsrpc的视频教程中的案例,我们需要做一个简单的CRUD,使用mongoDB。

我们需要在本地启动我们的mongoDB服务,然后我们需要添加一些代码到后端backend项目中。

代码开始之前,我们需要安装mongoDB的依赖,我们可以更方便的引入类型定义以及各种数据库方法。

npm install mongodb -s

为了和视频教程统一,我们的工具类/架构方式,将直接挪用视频教程中的代码:

  • 写一个数据库的表模型,名为Post.ts
// shared/protocols/models/Post.ts

export interface Post {
  _id: string;
  author: string;
  title: string;
  content: string;
  visitedNum: number;

  create: {
    uid: string;
    time: Date;
  };

  update?: {
    uid: string;
    time: Date;
  };
}

我们的数据库模型是需要共享到前端的,方便前端工程能够复用,但是为了确保后端的类型安全,我们需要在模型上多做一层处理。mongodb的id属性不是string,而是ObjectID,所以我们需要在后端对模型进行类型重写(只重写id字段)。

关于为什么要在后端多做一层封装是因为不可能在前端引入mongodb中的objectID

// shared/protocols/models/dbItems/DbPost.ts

import { ObjectID } from "mongodb";
import { Overwrite } from "tsrpc";
import { Post } from "../Post";

export type DbPost = Overwrite<Post, {
    _id: ObjectID
}>

我们使用tsrpc提供的Overwrite泛型对刚刚写的Post类型进行改写,将mongodb中的objectID类型引入进来进行替换,然后我们后端工程就要使用这个Dbpost类型,而不是刚刚我们写的Post类型。

  • 数据库相关配置
// 配置文件
// shared/protocols/models/BackConfig.ts (models文件夹是新建的)
export const BackConfig = {
  // 替换数据库url,数据库名test
  mongoDb: "mongodb://localhost:27017/test",
};
  • 定义数据库初始化类
// 全局一个类,里面写了初始化数据库的内容
// shared/protocols/models/Global.ts

import { Collection, Db, MongoClient } from "mongodb";
import { Logger } from "tsrpc";
import { BackConfig } from "./BackConfig";
import { DbPost } from "./dbItems/DbPost";

export class Global {
  static db: Db;

  static async init(logger?: Logger) {
    logger?.log(`Start connecting db...`);
    const client = await new MongoClient(BackConfig.mongoDb).connect();
    logger?.log(`Db connected successfully...`);
    this.db = client.db();
  }

  static collection<T extends keyof DbCollectionType>(
    col: T
  ): Collection<DbCollectionType[T]> {
    return this.db.collection(col);
  }
}

export interface DbCollectionType {
  Post: DbPost;
}
  • 改写后端index.ts
// backend/index.ts 添加如下内容

import { Global } from "../src/shared/protocols/models/Global";

async function main() {
    // Auto implement APIs
    await server.autoImplementApi(path.resolve(__dirname, 'api'));
    // TODO:在这里初始化了数据库
    await Global.init(server.logger);
    await server.start();
};

ok,截止到目前,我们把第一张表的相关配置已经搞定了,请确保数据库已打开且配置正确,然后我们直接运行
一下服务器:

npm run dev

如果你运气好(狗头),那么你应该是成功开启这个服务器,并且控制台能看到连接成功的信息:

WX20210719-223641@2x.png

然后我们快速开发一下新增API,其他的更新和删除API,希望能大家举一反三,自行开发。

// shared/protocols/PtlAddPost.ts

import { Post } from "./models/Post";

export interface ReqAddPost {
  newPost: Omit<Post, "_id" | "create" | "update" | "visitedNum">;
}

export interface ResAddPost {
  insertedId: string;
}

我们规定的请求类型是只能让客户端传递除了id,create,update,visitedNum的Post类型。
然后我们还是运行那几个熟悉的命令:

npm run proto
npm run api
// api/ApiAddPost.ts

import { ApiCall } from "tsrpc";
import { Global } from "../shared/protocols/models/Global";
import { ReqAddPost, ResAddPost } from "../shared/protocols/PtlAddPost";

export async function ApiAddPost(call: ApiCall<ReqAddPost, ResAddPost>) {
  let op = await Global.collection("Post").insertOne({
    ...call.req.newPost,
    create: {
      uid: "xxx",
      time: new Date(),
    },
    visitedNum: 0,
  });
  call.succ({
    insertedId: op.insertedId.toHexString(),
  });
}

这一part完成~

如何做到动态类型校验

之前我们就提到过,前端在调用后端的api时候,会给出完整的代码提示,从api名称到api的请求体类型等等,那么这一定程度上杜绝了开发中常见的接口联调不细心的问题。在传统的前后端开发中,尤其是分离模式,有一个非常常见的问题就是动态类型校验。每个语言/框架都有自己类型校验的手段,比如springmvc我们可以通过注解的方式来校验(下面展示了控制器中的校验,还有其他校验手段):

@Controller
@RequestMapping("valid")
@Slf4j
public class ValidateController {
 private static final String BASE_PATH = "/valid/";
 @RequestMapping("index")
 public String index(@Validated() Student student,BindingResult result){ 
        return BASE_PATH + "index";
    }
}

那么tsrpc是如何保证数据传输的正确性的呢,首先我们如果在前端使用tsrpc的浏览器请求包,我们调用api时候不仅会在开发中提示开发者这个字段是错误的,而且会在请求发出之前做前端方面的遏制。在后端请求到达异步函数之前,也会去做第三次校验;所以我们在后端异步函数中使用到的参数一定是类型安全,完全不需要担心安全问题。

市面上有很多js领域解决动态校验的方案;最常见应该就是json schema,可以基于json自己实现一套校验方法可以在运行时来做校验。但是仍然有很多缺点,比如不能在前端进行运行时提示且可能重复写很多类型定义。那么tsrpc核心中使用到了一个库(这个库也是同个作者开发的):

tsrpc-buffer

为了实现ts动态类型校验,不可能把整个ts加进去,因为那有足足60m多,这是不现实的。所以作者开发了这个库。tsrpc依赖了这个库,它对ts的语法进行了兼容,目前支持了大部分的ts的写法,包括我们常用的string,number等,还支持一些复杂的泛形。

如果你想细细了解这方面,可以看一下文档支持的ts类型有哪些

当然,随着ts的更新,这个buffer也会支持更多的ts类型,可以做更完善的全栈应用。而且我们可以使用tsrpc进行原汁原味的ts开发,市面上的第三方工具/框架需要借助另外编程语言/DSL,tsrpc-buffer完全让你使用ts,你不会感觉到一丝违和感。

二进制序列化

tsrpc的二进制序列化机制是由我们上文中提到的tsrpc-buffer中实现的,那么这个特性带给我们的是比json更小的传输体积且支持更多的数据类型,ArrayBuffer, Date等。这意味着使用tsrpc的全栈应用在应对上传图片这种业务的时候简直就像是小儿科,我们可以用一个例子来证明。

// 定义一个协议在后端,PtlUpload.ts
export interface ReqUpload {
    fileName: string,
    fileData: Uint8Array
}

export interface ResUpload {
    url: string;
}

我们通过刚刚学到的一些命令,来生成协议以及api

npm run proto
npm run api
// api实现, 先提前把uploads文件夹建立好,或者使用mkdir方法
import { ApiCall } from "tsrpc";
import { ReqUpload, ResUpload } from "../shared/protocols/PtlUpload";
import fs from "fs/promises";

export async function ApiUpload(call: ApiCall<ReqUpload, ResUpload>) {
  await fs.writeFile("uploads/" + call.req.fileName, call.req.fileData);
  call.succ({
    url: "http://127.0.0.1:3000/uploads/" + call.req.fileName,
  });
}

为了让前端调用,同步shared下的协议

npm run sync

写一个简单的file选择器在index.html中

<input type="file" id="fileInput">
// index.ts
import { HttpClient } from "tsrpc-browser";
import { serviceProto } from "./shared/protocols/serviceProto";

// Create Client
let client = new HttpClient(serviceProto, {
  server: "http://127.0.0.1:3000",
  logger: console,
});

const input = document.getElementById("fileInput") as HTMLInputElement;

input.addEventListener("change", async () => {
  if (input.files) {
    const fileData = await loadFile(input.files?.[0]);
    upload(fileData, input.files?.[0].name);
  }
});

const upload = async (fileData: Uint8Array, fileName: string) => {
  const fr = new FileReader();
  client.callApi("Upload", {
    fileData,
    fileName,
  });
};

function loadFile(file: File): Promise<Uint8Array> {
  return new Promise((rs) => {
    let reader = new FileReader();
    reader.onload = (e) => {
      rs(new Uint8Array(e.target!.result as ArrayBuffer));
    };
    reader.readAsArrayBuffer(file);
  });
}
// 同步到前端
npm run sync

开发完毕,我们可以仔细看一下控制台:

1626218954109.jpg

1626218957442.jpg

尽管我们在日常开发中会用到一些组件库,组件库帮助我们做了上传的大部分工作,所以我们写原生的上传可能在代码量上更多,但是省去了前后端转换Formdata的时间。

向后兼容http(json)和WebSocket

tsrpc也向后支持json,我们可以在客户端进行一个简单的配置,发送的请求就是json啦:

// Create Client
let client = new HttpClient(serviceProto, {
  server: "http://127.0.0.1:3000",
  json: true,
  logger: console,
});

1626391401179.jpg

我其实暂时没有想到非要使用json的场景,使用二进制序列化比json体积更小传输更快,本地开发的日志也在控制台随时打印,所以我还是建议大家使用默认的二进制序列化的传输模式。

tsrpc设计之初是为了游戏,因为传输特性能让websocket更高效,我们可以用tsrpc简单做一个websocket-demo,具体实现我参考了官网的实现,如果你想直接了解官网的这一part的内容,直接移步:

websocket实时服务-tsrpc

tsrpc的实现和协议无关,意味着咱们之前写的代码都可以用,仅仅做一个简单的调整替换即可。

websocket的消息是tsrpc传输中最小单元,我们需要用另外一个方法去定义协议,我们的websocket例子如下:

客户端发起一个请求,服务端接收并且向所有客户端发送一个消息

首先我们需要定义一个MsgHello.ts这样的协议:

// shared/protocols/MsgHello.ts

export interface MsgHello {
  time: Date;
  content: string;
}

这个协议规定了前后端通讯的请求体。

我们需要改写后端backend中的index.ts,将原先的HTTP服务,改成Websocket服务

import { HttpServer, WsServer } from "tsrpc";

export const server = new WsServer(serviceProto, {
    port: 3000,
    logMsg: true
});

这里导出server是有用意的,我们将在之后的代码中会用到这个server。

改写frontend前端中的index.ts

import { HttpClient, WsClient } from "tsrpc-browser";

let ws = new WsClient(serviceProto, {
  server: "ws://127.0.0.1:3000",
  logger: console,
});

const init = async () => {
  // 与后端webscoket服务建立连接
  let result = await ws.connect();
};

init();

我们需要一个api来触发后端给client发送websocket消息:

// shared/protocols/PtlSend.ts

export interface ReqSend {
  content: string;
}

export interface ResSend {
  time: Date;
}

定义成功后,我们运行以下几个命令:

npm run proto
npm run api

运行成功,我们可以在api文件夹下的ApiSend.ts中写入以下内容:

import { ApiCall } from "tsrpc";
// 这里引入banckend/index.ts 导出的server
import { server } from "..";
import { ReqSend, ResSend } from "../shared/protocols/PtlSend";

export async function ApiSend(call: ApiCall<ReqSend, ResSend>) {
  const time = new Date();
  call.succ({
    time,
  });
  // 广播给所有客户端
  server.broadcastMsg("Hello", {
    content: call.req.content,
    time,
  });
}

我们的后端逻辑写完了,我们运行以下命令,将协议同步到前端

npm run sync

我们进一步改写前端frontend/src/index.ts:

import { HttpClient, WsClient } from "tsrpc-browser";
import { serviceProto } from "./shared/protocols/serviceProto";

let ws = new WsClient(serviceProto, {
  server: "ws://127.0.0.1:3000",
  logger: console,
});

const init = async () => {
  let result = await ws.connect();
  console.log(result)
  if (result.isSucc) {
    // ws.callApi
    ws.callApi("Send", {
      content: "hello websocket",
    });
  }
};

init();

我们在页面初始化的时候,向后端发送刚刚写好的SendApi,这个时候我们既能收到api的返回,也能收到websocket的消息推送。

WX20210719-073153@2x.png

WX20210719-073210@2x.png

可以看到websocket传输也是二进制的,我们在开发中,也能发现,无论是callApi和发送websocket通知,从始至终都有类型推导,永远不会在传输中出现类型上的错误,这就是tsrpc的强大之处。

多平台

tsrpc支持多个平台,支持浏览器/小程序/原生ios 安卓/nodejs,甚至它还支持serverless,可以使用tsrpc开发基于阿里云/腾讯云的云函数;在后续我也会对tsrpc生态开发更多插件,使其兼容uniapp&unicloud,让他严格严格意义上跨多端,我相信tsrpc可以改变unicloud的开发习惯,让全栈应用更简单。

结语

本篇文章所有的知识点均在官网&视频教程有体现,视频教程在文章开始之前就有链接,非常希望大家能够先去看一下那个视频。tsrpc的教程还会出,下一篇关于tsrpc文章主要还是讲一下如何和serverless(unicloud)融合。这篇文章正在写大纲,相信也会在这个月之内能和大家见到。

v2-9765e9b4343a30b5fb43b3a227888221_720w.png

数据类型

  1. string
  2. number
  3. boolean
  4. array
  5. hash

以上是常见的数据类型

命名规则

常量,全部是大写字母

VERSION = "v1"

普通变量,如果不算@,@@等,我们一般直接以小写字母表达就可以了

age = 12;
user_name = "seho";

class和module只需要首字母大写和驼峰表达即可

class Apple

方法名,小写字母开头,结束可以用=,?结尾

def show
end

def index=
end

class写法

在ruby中,ruby的任何变量,string,number等都是object

class Api
  def show
    @article = "hello";
    color = "red";
    render_ajax_success({ msg: "123", text: color, age: age, name: name })
  end

  def index
    @article = "hello"
  end
end

名字首字母大写;class开头,end结尾;文件名称和类名一样,只不过改成小写和_拼接

如果我们要声明一个成员变量在类中,我们有一个更简单的方法去声明,并且自动写好了get和set方法:

attr_accessor "color"
attr_accessor "age"
attr_accessor "name"

类中同样有一个initialize方法,在initialize中我们可以定义class被实例化之后做的事情

同样的,ruby也有私有和公有方法

private
def getName

end

private标注一个方法为私有,那么没有标注的就是公有方法,在class中,一般私有方法是在公有方法的下方

赋值

ruby不像java,赋值需要声明一个变量的类型,直接赋值即可

age = 13;
name = "seho";

插值表达式

在js中,我们可以使用+来拼接变量达到组合的目的,ruby同样也可以

age = "13"
name = "seho"
puts age + name;

但是如果两个不同类型的变量拼接就会报错,因为ruby不知道如何转换,这个时候就需要用到插值表达式了

age = 13
puts "name: #{age}"

各种变量

类变量

# 多个实例都会共享的变量
@@age = 123;
A.new.@@age; # 123
B.new.@@age; # 123

实例变量

# 作用域是class类中,在ruby中广泛使用
@name = “seho”;

普通变量

# 作用域是在方法内
name = "seho"

全局变量

# 全局都可以使用的变量,不常用
$version = "v1";

方法

类方法

class A
    def A.show
    @article = "hello";
  end
end

实例方法

class A
    def show
    @article = "hello";
  end
end

他们的调用方法不一样

A.show
A.new.show

symbol

js在es6也有这个数据类型,ruby也表示内容永远不变,一般用于表达hash的key,声明只需要前面加冒号就可以了。

:age = number;
# 声明一个hash,最需要用到symbol
time = {
    :name: "seho"
}

数组

num = [1,2,3,4]
# 数据类型混合着来
test = ["1",2, "1", :name, {:name: "seho"}]

Hash

hash有3种写法

jim = {
    :name => "jim",
    :age => 20
}
# 1.9之后产生的新写法
jim = {
    name: "hello",
    age: 20
}
# 也可以写成
jim = {}
jim[:name] = "jim"
jim[age] = 20

hash的key,string和symbol不同,例如

jim = {
    :name: 1,
    name: 2
}

a[:name] #=> 1
a["name"] #=> 2

条件判断语句

# if else end是最常见的
a = 1;
if a == 1
    puts "a is 1"
elseif a == 2
    puts "a is 2"
else
    puts "is not"
end

同样还有case分支语句

a = 1;
case a
    when 1 then puts "a is 1"
    when 3,4,5 then puts "a is [3,4,5]"
    else puts "a is not in there"
end

三元表达式

a = 1
puts a == 1 ? "one" : "not one"

循环

# for
[1,2,3].each { |e| 
    puts e;
}

# 等同于下方
for e in [1,2,3]
    puts e
end

for和each几乎一样,for是关键字,而each是数组的一个函数;高手都用each,因为ruby和js有相像之处,for中的e变量,是全局的,循环之外,仍然可以访问到它,而each可以说是局部变量;

# loop
loop do
 # code
 break if 表达式
end

# while
begin
 # code
end while 表达式

更推荐使用loop,因为可读性更强,我不熟悉ruby,我都要使用loop是因为它能给我带来最直观的语义,而while的语义明显拗口。

剑指题解


互联网人题解神器,神器在手,offer我有

随手刷题 | 无广告绿色免费 | 个性推荐 | 每日自动推送 | 多维度丰富领域知识

项目的起源是一个 github 仓库,名为前端知识每日 3+1,正是这个优秀的题库项目才有了我们团队开发聚合题库的想法。在我们国内程序员圈子,大多数小伙伴为了更好的技术发展和 offer,都通过训练题来提高自己的眼界,其中不乏包括算法/业务/软技能的题目,但是因为我们的环境和侧重点不一样,所以我们接触的项目和题目都不一样,而市面上很少有一个项目能做到,涵盖不同圈子的聚合题库。 《剑指题解》项目就是一个优秀的互联网行业聚合题库神器。


开源免费不易,请关注我们的公众号以及开源仓库

沈昊Seho/InternetQuestionBank<br/>
Github 点 star, 拉取代码时请选择最新分支
Dcloud 插件市场支持我们

目录

特点

  1. 汇聚优秀的开源题目,以技术社区为主线展开的刷题 APP
  2. 根据用户的喜好,去推送相关的最新题目,不会再刷到 “牛头不对马嘴”无用的题目
  3. 我们绑定了服务号,每日定时推送给用户最新的题目,让你能在每天清晨就开启活力新一天
  4. 刷题 APP 无广告,无硬性推广,一切盈利皆在赞助/官网流量
  5. 我们还有很多有意思的待开发 feature,比如大佬带萌新模式,还将开启校园/企业的通道,让每一个学生/员工都能每时每刻提高自己
  6. 剑指题解开源项目,从原型图到小程序,UI 图,UML,API 文档,第三方 API 对接说明书,使用说明都将免费开源,欢迎各位同僚二开,让这个行业变得更好,我们将用心地辅助你们解决在技术上的难题。

技术栈

关于技术栈的选型可以参考我们的语雀文档
周边物料的开源,还需要感谢 mockPlus 以及语雀:

<img class="imgIcon" src="http://static.yinzhuoei.com/typecho/2021/01/17/69752764374919.png" width="10%">


<img class="imgIcon" src="http://static.yinzhuoei.com/typecho/2021/01/17/697832282748067.jpg" width="20%"></img>

UML

由于此部分在开发新 feature 的工作中可能会进行变更或者本就有实质性的错误,欢迎向我们反馈错误,我们将感激不尽。

类图

拉取任务

更多

更多相关资料请移步
out/doc或者语雀-UML

设计概览



设计源文件请移步:点击查看-为了您的体验,请务必下载最新版本的设计图
原型图在线预览: 点击查看, 如果失效请联系我们

API

《剑指题解》团队在 API 层面,开源的内容非常有限制,因为涉及到多位合作伙伴的题库授权,所以如果您要使用聚合题库 API 请联系我们商议,如果您是学习者,那么在语雀中的 API 文档会帮助到您,在这里我们将简单介绍。

uniapp 的云函数开发,我们是第一次接触,所以在此之前我们做了很多调研,在下文也提到了关于 explain.js 这个框架的作用,我们也是非常感谢 uniapp 开源社区有这样创作质量的第一批的 unicloud 框架开发者,在咨询了官方人员之后,我们了解到如果要使用 ts 来开发,需要把 ts 编译成 js 来放在云函数目录,所以我们选择了以下的技术栈,开发者可以很爽的使用 ts 来开发 unicloud 云函数:

  • esbuild-node-tsc
  • nodemon

esbuild-node-tsc 这个工具是基于 esbuild 的,利用其特性,我们可以比 ts 官方提出的 tsc 编译工具更快,快几十倍到上百倍,这也是得益于 esbuild 的使用 go 语言直接编译的特性。

nodemon 可以帮助我们监听目录文件,一旦改变就执行打包 ts 命令直接将编译好的 js 放在指定目录
所以我们把 explain.js 的配置也相应的改变,我们在 services 目录中存储的是我们 ts 文件,在 dist 中存储的是我们编译好的 js 文件,而 dist 目录就是 explain 需要的:

config.init({
  baseDir: __dirname,
  serviceDir: "/dist/",
});

需要注意的事情是:
由于云函数大小限制,我们需要把相关依赖全部安装到全局,包括 typescript

读写职责分离

读写职责分离模式(CQRS)是一种把查询(Queries) 数据和和更新(Commands) 数据通过使用各自独立的接口分开的模式。
Uniapp 的 Unicloud 很好,其中的 jql 也是大开眼界,确实统一了前端开发操作数据库的体验,让我们前端开发操作数据库非常友好,但是我们使用 CQRS 将这些原本封装的很好的 API 不予以使用。

  • 前端使用 jql 去读取想要的数据
  • 写入操作就交给云函数去执行

如图,前端读取一个列表数据

const db = uniCloud.database();
const databaseName = "testInit";
// 获取测试数据列表
export function getTestList() {
  return new Promise((resolve) => {
    db.collection(databaseName)
      .get()
      .then((res) => {
        // res 为数据库查询结果
        resolve(res);
      })
      .catch((err) => {
        // err.message 错误信息
        // err.code 错误码
      });
  });
}

我们将这样的文件抽出了一个一个查询模块,每一个模块对应了一个数据表(这里是 testInit)封装在了 API 这个文件夹中。

Vue 页面将这样去调用,这样我们在 API 查询层将做好数据的处理,确保 Vue 拿到的是可以直接渲染的干净数据。

import { getTestList } from "../../api/test";
const data = getTestList();

我们写入操作将通过云函数直接调用,唯一设计不同的地方在于,我们将云函数这个概念改变了,应该是模块化,我们把一个一个函数变成了模块。

在我们的初步技术调研过程中,由于 Uni 官方并没有提供给用户云函数开发框架,但是我们在插件市场中找到了一款名为 explain 的开发框架,它可以迅速的帮助我们实现 restapi 风格的单路由云函数,这款框架我们不多做介绍,文档在这里:explain.js 快速开发 uni 云函数的框架

// 注册用户根据手机号
  addUserByPhone() {
    return handleMustRequireParam(
        [{
            key: "username",
            value: "用户名",
          },
          {
            key: "password",
            value: "密码",
          },
        ],
        this.event.params
      )
      .then(async () => {
        const {
          username,
          password
        } = this.event.params;
        if (!/^1[3456789]\d{9}$/.test(username)) {
          return appErrorMessage("用户名格式不正确");
        } else if (password === "" || password.length < 6) {
          return appErrorMessage("密码格式不正确");
        } else {
          // 校验手机号
          return await uniID.register({
            username,
            password,
          });
        }
      })
      .catch((err) => {
        return err;
      });
  }

那么我们如果要调用 testPrint 这个模块中的增加操作

uniCloud.callFunction({
  name: "application",
  data: {
    route: "api/user",
    method: "POST",
    params: {
      username: "18291563764",
      password: "sas",
    },
  },
});

贡献者

感谢为《剑指题解》这个优秀项目贡献自己一份力的小伙伴们:

如果您想加入到我们的贡献者队列中,请联系我们,这里还有相关贡献者的介绍,希望对您有帮助

核心维护者

相关开源物料

题库合作伙伴

QQ 群和钉钉群欢迎大家加入


许可

Apache License © 剑指题解
如果您在其他有关我们的文档见到了与之不符合的协议内容,请联系我们,这可能是我们的历史遗留的代码问题。

Nginx是一款高性能服务器,最近这几年非常火,以轻量且高并发,高性能著称,那么此笔记将不会从0开始讲解API,而是会从各种问题入手,通过问题学习nginx。


特点:

  1. IO多路复用
  2. 高性能
  3. 高并发
  4. 占用系统资源少

Untitled.png

Nginx作为一个WEB服务器,有着大好的未来,市场份额非常给力,同时也是份额上升速度最快的web服务器。

Untitled.png

Nginx作为前端来说,需要学习什么?我们只需要学习Nginx在应用部署,反向代理,处理资源的进程,亦或者是搭建网站的基础知识,如果你还没有一个blog,那么就从现在开始学习nginx并且搭建你的第一个网站吧。


反向代理与正向代理

Untitled.png

我们在平时上外网的时候,比如谷歌,youtube,twitter,Ins等,如果使用我们内地网络,是访问不成功的,只有在香港台湾或者境外才能访问到类似的外网。那我们需要通过内地网络去访问外网只能通过一个proxy代理去做一个请求的转发,我们的内地网络请求在到达外网地址之前,会经过一层代理,这个代理会去请求外网,请求成功之后会把页面呈现给我们的客户端。

在这个过程中,外网服务器不知道我们的内地网络是谁,只知道代理地址,所以对于外网服务器来说,请求的真实客户端是看不到的。那么这个过程就叫做 正向代理,proxy代理的是客户端。

Untitled.png

反向代理是相反的,代理的是服务端,对于客户端而言,访问的服务器仅仅是多个真实服务器的一个代理而已,所以对于客户端用户而言,真实服务器的信息是不可见的。这样的过程也就是反向代理,proxy代理的是服务端

Nginx如何去做反向代理?

server{
        listen 80;
        server_name nginx.yinzhuoei.com;
        location / {
               proxy_pass http://yinzhuoei.com;
        }
}

其他的proxy配置:

proxy_set_header :在将客户端请求发送给后端服务器之前,更改来自客户端的请求头信息。
proxy_connect_timeout:配置Nginx与后端代理服务器尝试建立连接的超时时间。
proxy_read_timeout : 配置Nginx向后端服务器组发出read请求后,等待相应的超时时间。
proxy_send_timeout:配置Nginx向后端服务器组发出write请求后,等待相应的超时时间。
proxy_redirect :用于修改后端服务器返回的响应头中的Location和Refresh。

解决跨域

通过反向代理解决跨域:

server
{
   listen 3003;
   server_name localhost;
      ##  = /表示精确匹配路径为/的url
   location = / {
       proxy_pass http://localhost:5500;
   }
   ##  若 proxy_pass最后为/ 如http://localhost:3000/;匹配/no/son,则真实匹配为http://localhost:3000/son
   location /no {
       proxy_pass http://localhost:3000;
   }
   ##  /ok/表示精确匹配以ok开头的url,/ok2是匹配不到的,/ok/son则可以
   location /ok/ {
       proxy_pass http://localhost:3000;
   }
}

加header头允许跨域:

server
{
    listen 3002;
    server_name localhost;
    location /ok {
        proxy_pass http://localhost:3000;

        #   指定允许跨域的方法,*代表所有
        add_header Access-Control-Allow-Methods *;

        #   预检命令的缓存,如果不缓存每次会发送两次请求
        add_header Access-Control-Max-Age 3600;
        #   带cookie请求需要加上这个字段,并设置为true
        add_header Access-Control-Allow-Credentials true;

        #   表示允许这个域跨域调用(客户端发送请求的域名和端口) 
        #   $http_origin动态获取请求客户端请求的域   不用*的原因是带cookie的请求不支持*号
        add_header Access-Control-Allow-Origin $http_origin;

        #   表示请求头的字段 动态获取
        add_header Access-Control-Allow-Headers 
        $http_access_control_request_headers;

        #   OPTIONS预检命令,预检命令通过时才发送请求
        #   检查请求的类型是不是预检命令
        if ($request_method = OPTIONS){
            return 200;
        }
    }
}

Master&Woker模式

Nginx启动之后,启动了80端口进行服务监听,那么进程中就存在一个Mater主进程和多个Woker进程;

Untitled.png

Master进程的作用就是:读取&验证nginx.conf配置文件并且管理多个woker进程;接受外部信号;监控Woker,如果Woker挂掉,将自动重启Woker;

Woker进程的作用就是:多个Woker会拦截所有的请求并做出处理;每一个woker进程维护一个线程;woker的个数和CPU有关,从nginx.conf配置woker个数,配置几个就是几个,但是要避免配置过多,要充分利用CPU;

一个请求到响应的流程:

  1. Nginx启动,Matster进程根据nginx.conf初始化;初始化监听socket;fork出多个woker进程;
  2. 发起请求
  3. woker进程们一起竞争,胜出者通过三次握手,建立socket连接,处理请求。

如何做热部署呢?

热部署就和前端热部署一样的性质,即修改配置文件,不需要重启服务器就可以使用最新的配置。

nginx -s reload

通过这样的一个命令即可热部署,无需重启,随时改随时用。

一般情况下,我们做热部署可以有几个方案,比如前端,webpack的本地开发工具,webpack-dev-server,即本地启动一个服务,开启一个websocket,当我们的文件改动,就重新加载这个css/js。

而nginx也是同样的方式么?我们的主进程master去发布一个修改请求,然后woker去订阅这个消息,实现类似这样的热部署?

其实不然,nginx使用的是如下的方案,当master监听到配置文件的更改,会创建一批新的woker去执行新的请求,老的woker进程会在任务处理完毕之后,再由master杀掉进程。


如何做到高并发?

Nginx采用多进程+异步非阻塞方式(IO多路复用):

关于异步和同步,我需要做一些概念上的整理;

同步和异步指的是消息的通信机制,我们做web开发是最能理解同步异步的区别的,因为我们天天和接口打交道;

1)所谓同步指的就是发起一个请求/调用,在没有得到结果之前就不会返回,一旦得到结果就立即返回;

2)所谓异步指的就是发起一个请求/调用,调用者不会主动去care被调用者,而被调用者拿到结果之后会通知调用者

而阻塞非阻塞指的是程序在等待调用结果时的状态

1)阻塞调用指的就是,结果返回之前当前线程被挂起,调用线程在返回之后才返回;那么挂起的这个线程是会被阻塞的;

2)非阻塞调用指的就是,不能立刻得到结果之前,线程是不会被挂起的,仍然可以做其他事情;那么非阻塞调用如何知道得到结果了呢,需要定时去check的;

关于阻塞IO和非阻塞IO等我总结完了再说哈,还有关于Nginx的IO多路复用Epoll模型,这个是延申知识了,我也需要学习整理哈,现在还不清楚这一块的东西。

Nginx后续章节过段时间发,中间要发几篇shadowDom和剑指题解的文章,大家耐心等待...


学习资料如下:

nginx如何做到高并发?
8分钟带你深入浅出搞懂Nginx
理解同步异步&阻塞非阻塞

微信截图_20210331210548.png