分类 后端 下的文章

前段时间,和群友偶然聊到了GUI话题,期望可以使用GUI应用来替代前端工具,比如代码规范/校验等,但是查找了相关资料,关于代码管理类的GUI程序非常少(甚至说是一个空白),总结了以下大概有以下几点:

  1. 安全性问题,用户不期望将代码上传到服务端
  2. GUI设计不贴合程序员,提升不了工作效率
  3. 极难扩展,大多数基于代码管理的SASS服务,程序员不可以开发插件去扩展已有功能

如果你是Mac用户,相信你一定知道RayCast,RayCast本质上是一个代替Mac启动器的应用程序,但是由于其强大的插件机制,可以让前端开发者方便地开发各种插件,比如翻译/词典/截图等等,如下图是我安装的一款颜色选择器插件:

RayCast的最佳设计不仅限于此,它还提供了‘快捷键’功能,使其通过预定义好的快捷键,轻松的唤起用户装的指定扩展,例如在Mac中有一款大名鼎鼎的剪贴板软件叫作Paste,而在RayCast中可以直接免费的使用类似的功能,装好指定的插件之后,只需要快捷键就可以唤出剪贴板软件:

另外,有曾接触过运维的同学们应该都知道一个国产的面板应用“宝塔”,虽然这款软件面向的是初级运维人员,但是其免费易用的特性让不少非专业的开发人员也能管理服务器。比如本博客就在使用宝塔软件,是我在上高中时使用宝塔搭建的,已经运行有5,6年了。抛开安全问题,作为一个在当时不懂运维的同学也能深深产生依赖,一直沿用至今,这或许就是GUI的魅力。

读者们对于GUI程序并不陌生,我们在使用的Mac,iphone等操作系统都在时时刻刻的服务用户;同样我们开发者领域也有一些出色的GUI程序,比如Clashx,SourceTree帮助着我们日常开发。

但是有没有一款工具可以改变开发习惯,真正的提高效率,并且兼顾安全。拿前端领域举例,诸如Eslint等工具层出不穷,包管理工具也是五花八门,而且作为一名优秀的开发者无时无刻都在使用工具去减少重复劳动,我们需要有大量的额外工具,例如JSON校验,Mock服务,Local Server,甚至是代码优化,日报/日志工具。

很遗憾,我们目前并没有这样的软件去集成这些五花八门的工具,但是读者们会心里想,我可以唤出浏览器打开一个工具网站就可以解决所有问题,为什么还需要一个GUI软件去做这样的事情呢?这也是本篇文章的重点。

依赖管理

NPM对于前端开发者来说并不陌生,我们想要安装一个库其实要做很多调研,大多需要查看其更新时间,下载量以及issue数量等等;但是当你有一天想要开发Rust程序时,你并不知道你要编写的功能需要安装什么库,你也不知道库和库是如何依赖的,这势必要浪费很多时间,所以如何统一各个语言的依赖管理呢?

这里实现技术也比较简单,无非就是将用户所期望的依赖底层通过命令行的形式进行安装;GUI的重点是如何查找到开发者心仪的依赖,下图是使用NPM搜索PDF关键字的结果:

在默认搜索模式下,在搜索如此具体的关键字时,仍然找不到pdf.js的核心库,如果我是一个对前端领域比较陌生的开发者,我大概率会安装第一个依赖。虽然举得这个例子有点极端并且有一点杠精,但是作为一款跨平台的代码项目管理工具,我们要做的是多个流行语言的包管理工具,对于经常跨语言开发的开发者,你不再需要打开多个标签页并且你也不需要熟悉你安装依赖的命令,只需要2步:

  • 精确地搜索出依赖列表
  • 右键单击鼠标,你的依赖会自动下载

如果在依赖列表中看到了你已安装的依赖,你完全可以双击右键去卸载它,这在我们频繁查找与安装依赖的场景非常有用,并且节约时间。

另外依赖检索列表中,会借鉴NPM的搜索模式,并且在之上结合搜索引擎和ChatGPT,会给你一个智能的依赖推荐,尽管你搜索的关键字和实际的依赖完全不同,你也能快速安装它!并且智能推荐是联网的,你无需GPT4,软件内部会将搜索引擎的结果提供给GPT,让GPT提取可用的依赖信息。

代码体检?咋有点像360?

代码体检的功能是一个仍然在探讨的feature,所以本章和最后完成的功能差距可能会非常大。在一些代码管理的SASS产品中,可能会提供这种功能,并且相对耗时以及昂贵。那么我们如何做这个功能呢?当代码项目被添加到软件中时,会经过一次极快的预处理,会在短时间内得出项目的技术栈和依赖关系,基础的模块数量。

在真正体检时,会得出一份报告,这个报告生成的过程不会产生联网的请求,一切都在本地完成,不依赖GPT。因为大额的代码段落让GPT分析会造成很多Token消耗,这一般人不太用得起(笑哭)。所以这块可能会使用开源的一些检测工具去扫描代码库中可能重复的函数,并且行数特别多的函数,并且注释的数量多少也会影响报告分数;

报告中的检查项可以手动关闭,因为开发者的习惯并不一样,有些人并不习惯编写注释,也有一些项目避免不了行数过多的函数。如果关闭对应的检查项,软件也会跳过这些步骤,势必会更快地生成报告。

工作流

软件会提供一个基础的工作流,将软件项目和快捷键绑定,甚至和其他软件绑定。你可以理解为迷你版的快捷指令,可以用代码编写工作流中的代码,比方说你有一个很重很大的项目,当你有一天重启了电脑或者应用崩溃闪退,导致你不得不重新打开这些应用。那么你就可以自定义一个工作流,当你使用快捷键唤出项目时,你的数据库,Docker,Redis就全部按照你的指示有顺序的跑起来了!

另外当你编写代码时,软件会每隔一段时间去检测你正在编写的模块,找到你经常更改的代码块,并且自动的给出你优化的建议(会有一个提示消息,和Copilot不同)。

利用到GPT的地方会有很多,比如当您把代码提到暂存区时,软件就已经分析其代码并且对代码做总结,生成好commit message,你只需要看到消息之后,点击确定,就可以应用这个提交。

除了显性的工作流之外,在软件内部,不管是体检的预处理分析文件还是你在编写代码时候产生的输出,都有可能会给软件其他功能提供支持。比如日志/日报插件,它会总结你一天的Commit message和具体的工作任务,你可以为其定义一个日报模板,只需要稍加修改,就可以一键发布;发布到哪儿呢?Github/Gitlab/钉钉,只要存在第三方接口的应用,自己编写逻辑,就可以直达!

插件

还记得一个成功的软件核心是什么吗?就是插件,软件可以允许用户使用HTML/JS定义界面,使用Rust/WASM去定义其实现,关于支持Nodejs?这个得看后续啦。

插件分为2类:

  • 核心官方插件(上述的几个功能都属于核心插件)
  • 第三方插件

这是借鉴了Obsidian,对第三方插件必须要提供源代码并且接受监管,如果对于非常流行的第三方插件会合并到官方插件中,但是会继续由社区开源爱好者(和官方开发者)一起推动维护。

有了第三方插件,你脑海中的各种工具,比如JSON校验,Mock服务,Format等等,把那些工具网站都删除了吧~,只需要一个快捷键就可以抵达的插件,无论是性能还是便捷性,本地的软件都会更胜一筹。

总结

对了,软件的名字叫作YourPanel,现已开源:

https://github.com/yourpanel/core (Next.js + Tauri)

文章中的GPT相关功能,都可以关闭,关闭这些GPT调用之后,任何有网络连接的插件在运行时,都会让你知晓;并且在安装插件时,你也可以清楚地看到插件是否基于网络连接!

作为一个本地的代码管理面板软件,我期望你的代码只允许你一个人访问,并且期望用较少的网络连接去完成任务。在初期使用中,我们没有计划引入用户管理,这意味着软件没有登录,我们也看不到你的代码和你的任务。

我们的初心就是:在一个安全环境下,提供开发者便捷服务。

作为一个没有学过C/C++的开发者(比如说我),在初步学习Rust中需要了解一些JavaScript中不存在的东西,那么所有权就是Rust中的核心功能之一。我需要一篇文章记录这一次的学习,在真正内容开始之前我需要描述一些基础知识,在最后也会简单看看”引用和借用“,”slice类型“这些与之相关的Rust概念。因为是Rust初学者,请大家阅读本篇文章带着自己的思考,因为每个人的思考方式和理解都不一样,所以可能会导致某些错误...。

参考资料:

预热

程序员编写的所有程序都必须管理其使用计算机内存的方式,比如说JavaScript这一类的语言具有垃圾回收机制(GC)它可以不断寻找不再使用的内存,因此不需要开发者手动干预;在另外一些语言中,我们需要亲自分配和释放内存;但是在Rust中就利用了所有权的概念管理内存,编译器在编译阶段会根据规则进行检查,同样的也不需要程序员手动干预内存,这些有关内存的工作都交给了所有权和编译器。所以我们学习Rust中的所有权的时候,准确说应该是学习编译器是通过什么“规则”来进行检查的,这个规则对我们更重要。

堆栈

我们在写JavaScript的时候,通常业务开发我们不需要考虑堆栈,但是在Rust中我们需要考虑一个值是在堆上还是在栈上,这和所有权息息相关,所以我们先简单回顾一下堆和栈的基础知识。堆和栈都是代码运行时可使用的的内存,它们是不相同的。大家都知道栈是先进后出,像服务生手中一个一个托盘一样,最先放入的托盘一般都会最晚从上方取出;栈中所有数据都占用固定的大小,程序在编译时都会看值大小是否可能会变化,如果是则需要存储到堆中,因为堆中所有的数据都是凌乱的,需要分配器去在堆中区域开辟一块空间,标记为已使用并且返回指针,这个过程也叫做在堆上分配内存

数据直接入栈当然也比在堆上分配内存更快,入栈直接放到栈顶即可,入堆的话分配器不仅要查看内存,分配内存还要记录以便为下一次分配做准备;访问堆上的数据也比访问栈中的数据要慢(因为指针),而因为处理器缓存的原因,跳转越少就越快,当访问的数据彼此都是栈中那自然快,但是彼此数据一个在栈中一个在堆中那自然会稍慢一点。那么所有权将会帮助我们处理堆中的重复数据,无用的数据以及跟踪哪些代码在使用堆中数据,总的来说所有权的存在就是为了管理堆数据。

所有权规则

  • 每一个值都有一个被称为其所有者的变量
  • 值在任一时刻只有一个所有者
  • 当所有者(变量)离开作用域的时候就会被丢弃

感觉目前理解这几个规则还比较晦涩,但是我们可以随着笔记深入慢慢地理解

作用域

所有权规则中既然提到了作用域,那我们简单看看作用域,其实和JavaScript相差不大,我们非常容易理解

fn main(){ // a在这里无效
    let a = "hello seho" // a有效
} // 作用域结束了,a无效
看起来很简单不是么?和JS一样?

我们此时会发现hello seho这个值是通过字面量硬编码创建了一个字符串并且绑定到a中,这种情况下a将会在栈中,因为它有着固定的大小,那么当作用域结束之后,a将会直接出栈。

内存和分配

对于以上的hello seho这种值,在我们的实际业务中并不常见,我们大多数会存在一个大小不定的内容,而且会在程序的运行时增大或者减小,那么为了支持这种需求只能在堆中开辟出一块内存,意味着程序需要在运行时去创建这块的区域,而且当我们处理完内容,需要把内存还给内存分配器。

第一步很好说,我们调用String::from这种方法就可以在堆中创建一块区域,那么第二步怎么还呢?

在C中是需要手动的释放内存的,但是程序终究是人写的,有时候忘记还了,有时候过早还了,有时候重复还了,都会造成系统bug。在有GC的语言中,GC会记录并且清除不使用的内容从而释放内存,所以这也就是我为啥喜欢JS的原因了,真的太爽了....

我们把上面的代码改造一下

fn main(){ // a在这里无效
    let a = String::from(""hello seho"") // a有效
} // 作用域结束了,a无效
在作用域结束之后,rust会调用一个特殊函数drop从而释放内存,在stirng内部会实现这个drop函数...

移动

我们在JS中写过很多这样的代码

let a = 1;
let b = a;

很显然,它在栈中创建了2个变量, a和b,他们的值都是1

在rust中也一样,但是如果我使用String::from创建变量呢,在堆中和栈中会有不同么?

let a = String::from("hello seho");
let b = a;

很显然,a的值是hello seho,b“复制”了a的指针,它们都存在栈中,指针指向了堆中的hello seho。

诶,难道说,rust的表现和js一样?在我们前面提到了所有权的规则之一,当变量离开了作用域将会自动释放,但是此时a和b都指向同一堆中内容,此时不是造成了多次释放的问题?答案是肯定的,在我们之前就说到过,没有释放,过早释放,多次释放都会对程序造成影响,所以rust在针对我们上面这种代码的时候,作出了一个处理即当a被“复制”到了b身上,此时a不再生效,程序会在编译期间报错。

如此看来,就避免了多次释放的问题,而“复制”也不是真正的复制,而是移动

那么如果真的想克隆一个一摸一样的值,可以调用clone方法,而我们在上面写了一个整型的例子,为什么没有调用clone也可以被克隆,是因为它们本身就是栈中的数据,在栈中的拷贝是快速的,不需要通过移动这种机制来实现拷贝

到此为止,我们应该能理解所有权到底是什么了,我们在本篇笔记中学习了所有权的概念和规则,以及复习了堆栈基本知识,还有rust的内存分配。我们在本篇笔记中大量的使用了js这门语言作为参照对象,如果你不熟悉js的话,下次笔记我会将语言的比较去掉,这样应该会更加容易理解,有问题发评论区,吴彦祖和迪丽热巴发的评论我肯定都会回复

请输入图片描述

原文链接-因卓诶-人类高质量男性开发的基于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)融合。这篇文章正在写大纲,相信也会在这个月之内能和大家见到。