seho 发布的文章

扫码_搜索联合传播样式-白色版.png

因卓诶原文链接

一个虚假的故事(狗头)

设想一下,如果10人以下的前端小团队要合作去协同一个项目;

这个时候leader说: "小明, 我们这次使用vue3吧,你去搭建一个项目吧!"

小明第一次开发vue3的项目,但是身为老司机却丝毫不慌,它按照官方文档一步一步的构建脚手架,去添加各种依赖,然后把之前vue2或者其他项目的公用代码复制过来。

1天过去了...

小红拿到了最新的脚手架代码,打开自己的webstorm;小蓝打开了自己的sublime;小紫打开了自己的vscode。数周之后,leader查看仓库,发现小红,小紫,小蓝的代码/架构风格迥异,而且代码毫无章法的蜷缩在仓库中,甚至出现了很多组件/公用方法重复的问题。几周之后慢慢迭代,慢慢地成为了“屎山”。当团队的人越来越多,这个代码可能就越“屎”。


前言

我从一毕业从事工作开始到今天,小团队接触的特别多,这种情况其实非常常见。但是由于时间和其他原因,一直没有机会把我开源项目的那一套技术栈搬过来;上周终于不是很忙就筹备了二个项目,用来解决小型团队的常见问题,成本不高,只需要一个包。在开始介绍之前,我们罗列一下问题列表:

  1. 开发工具不统一
  2. 代码规范以及美观性
  3. 初始化一个项目做的准备工作太多
  4. 技术迭代不够快

我们今天这篇文章主要讲述2个项目:

enjoy-project-tool仓库
enjoy-project-template仓库

命令行工具主要的作用就是拉取我们的代码模板,这个大家见怪不怪了,但是我仍需要描述一些细节给第一次做命令行的兄弟们看。至于项目模板,我开源的仓库的readme已经做了很多很多介绍以及使用方式,这里就不阐述过多了。

可以一边看完整代码,一边code

准备

使用nodejs开发命令行工具是对前端工程师最友好的方式,我们可以直接通过node去执行我们的js文件,所以我们随便npm init一个新的工程,然后更改我们的package.json

{
  "name": "enjoy-project-tool",
  "version": "1.0.0",
  "description": "",
  "main": "dist/main.js",
  "scripts": {
    "run": "nodemon",
    "lint": "eslint 'src/**/*' --fix",
    "prettier": "prettier --write '**/*.{ts,js,json,markdown}'",
    "lint-staged": "lint-staged",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "lint-staged": {
    "*.{ts,js,json,markdown}": [
      "prettier --write",
      "eslint --fix",
      "git add"
    ]
  },
  "bin": {
    "enjoy": "dist/main.js"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "chalk": "^4.1.2",
    "commander": "^8.1.0",
    "download-git-repo": "^3.0.2",
    "inquirer": "^8.1.2",
    "ora": "^5.4.1"
  },
  "devDependencies": {
    "@types/inquirer": "^7.3.3",
    "@types/node": "^16.7.10",
    "@typescript-eslint/eslint-plugin": "^4.30.0",
    "@typescript-eslint/parser": "^4.30.0",
    "esbuild-node-tsc": "^1.6.1",
    "eslint": "^7.32.0",
    "eslint-config-prettier": "^8.3.0",
    "eslint-plugin-prettier": "^4.0.0",
    "husky": "^7.0.2",
    "lint-staged": "^11.1.2",
    "nodemon": "^2.0.12",
    "prettier": "^2.3.2",
    "typescript": "^4.4.2"
  }
}

给大家描述一下,我们用什么技术栈去开发这个命令行工具?

首先我们需要typescript,使用esbuild-node-tsc(简称ETSC)去编译我们的ts代码;
其次我们使用husky和lint-staged以及eslint去对我们的代码做git提交之前的校验;
prettier主要做一个代码美化;
使用nodemon便于我们开发调试自动编译

如果你对上面的技术栈不是特别了解,没关系。我们再讲述具体实现的时候还会提到它们的。

开发命令行工具我们需要给node暴露一个js文件,那么这个文件我们可以存储dist目录下,并且我们需要描述一个名称,让node环境通过名称去找到对应的js文件并且运行,所以我们就在package.json中写下了这一行代码:

 "bin": {
    "enjoy": "dist/main.js"
  },

那么到时候我们可以使用如下的命令访问到我们的程序:

enjoy

我们只需要把我们写好的ts代码去编译到dist目录下就大功告成了~

hello world

纯属个人习惯,我把main.ts文件放到了src目录之下,然后我写下了下面的代码:

#!/usr/bin/env node

console.log("hello world")
#!/usr/bin/env node 必须加入这段代码,让代码使用node进行执行

这个时候我们需要配置一下ts和etsc:

根目录新建 tsconfig.json

{
  "compilerOptions": {
    "target": "es6",
    "module": "CommonJS",
    "outDir": "./dist",
    "strict": true,
    "esModuleInterop": true,
    "moduleResolution": "Node",
    "allowSyntheticDefaultImports": true,
    "resolveJsonModule": true,
    "forceConsistentCasingInFileNames": true,
    "typeRoots": ["./node_modules/@types", "./typings"]
  },
  "include": ["./src/*.ts"]
}

这个配置文件我们需要关心include,以及target,module,outdir,typeRoots;

include: 包含ts的目录
target: 我们编写的ts代码的版本,es6/es5...
module: 我们编译之后的模块类型,ems还是cjs,这里我们选择的是cjs
outDir: 输出js的目录
typeRoots: 类型声明文件存放的目录,我们一会需要在typings这个目录下去定义我们的类型

typescript自带了tsc用于编译ts文件,但是我们使用了etsc只因为它速度更快,etsc默认是开箱即用的,只用你根目录下的tsconfig.json配置它就能工作。但是我们仍需要它把编译之后的代码进行压缩,所以我们需要对etsc进行额外的配置,我们需要新建etsc.config.js

module.exports = {
  esbuild: {
    minify: true
  }
};

这个时候我们项目根目录运行etsc的命令,就可以看到根目录出现了dist文件夹并且其中有main.js的文件。我们就可以用nodejs去运行它了。

我们在命令行输入:

npm link

> npm link 命令可以把你写的工程 链接 到你电脑的全局环境,方便调试,而且不需要install

这个时候我们在命令行输入: enjoy

就可以在控制台显示出我们写的hello world了

优化开发体验

我们已经成功的打印出了hello world,我们每一次编辑main.ts文件都需要运行etsc命令,这就不是昊哥干的事情。所以我们用nodemon去监听文件的编辑,然后让nodemon去触发etsc的命令。我们需要在根目录新建一个nodemon.json:

{
  "watch": ["src/*"],
  "ext": "ts",
  "exec": "etsc",
  "legacyWatch": true
}

然后在package.json中我们已经写入了脚本命令:

"run": "nodemon",

执行

npm run run

这个时候nodemon服务已经启动了,我们更改main.ts,nodemon会自动去执行etsc命令,直接就把文件编译到dist里面了,然后我们就可以无缝的去在命令行调试最新的结果(npm link之后就不需要再次执行这个npm link了)

核心代码开发

commander这个库是用于nodejs开发命令行的最佳工具,我们可以使用这个包去完成很多事情,比如命令的控制等,我们可以在main.ts中写入以下代码:

#!/usr/bin/env node

// 命令行
import { program } from 'commander';

program
  .command('create')
  .description('create template (创建模板)')
  .action(async () => {
    // 回调函数
    console.log("回调函数")
  });

program.parse(process.argv);

program也可以支持命令行的version版本,这一块我就不描述了,具体可以查阅文档。

运行enjoy就能看到如下内容:

1630837375700.jpg

commander帮助我们把一些必要的生成完毕了,我们可以直接运用enjoy create就可以把我们刚刚注册的create的回调函数触发。

我们目前这个命令行工具支持创建模板的功能,所以我有必要把create这个操作抽离出去,所以我新建了一个ts文件专门处理create的逻辑。

然后代码改写:

program
  .command('create')
  .description('create template (创建模板)')
  .action(async () => {
    await import('./create.js');
  });

program.parse(process.argv);

我们新建的create.ts内容如下

#!/usr/bin/env node

import inquirer from 'inquirer';
import ora from 'ora';
import fs from 'fs';
import { exec } from 'child_process';
import download from 'download-git-repo';
import chalk from 'chalk';

const spinner = ora('下载模板中, 请稍后...');

// 模板字典
const template: { name: string; value: string }[] = [
  {
    name: 'vue3-vite2-ts-template (ant-design-vue)模板文档: https://github.com/seho-code-life/project_template/tree/vue3-vite2-ts-template(release)',
    value: 'seho-code-life/project_template#vue3-vite2-ts-template(release)'
  },
  {
    name: 'node-command-ts-template                 模板文档: https://github.com/seho-code-life/project_template/tree/node-command-cli',
    value: 'seho-code-life/project_template#node-command-cli'
  }
];

// 安装项目依赖
const install = (params: { projectName: string }) => {
  const { projectName } = params;
  spinner.text = '正在安装依赖,如果您的网络情况较差,这可能是一杯茶的功夫';
  // 执行install
  exec(`cd ${projectName} && npm i`, (error, stdout, stderr) => {
    if (error) {
      console.error(`exec error: ${error}`);
      return;
    } else if (stdout) {
      spinner.text = `安装成功, 进入${projectName}开始撸码~`;
      spinner.succeed();
    } else {
      spinner.text = `自动安装失败, 请查看错误,且之后自行安装依赖~`;
      spinner.fail();
      console.error(stderr);
    }
  });
};

// 修改下载好的模板package.json
const editPackageInfo = (params: { projectName: string }) => {
  const { projectName } = params;
  // 获取项目路径
  const path = `${process.cwd()}/${projectName}`;
  // 读取项目中的packagejson文件
  fs.readFile(`${path}/package.json`, (err, data) => {
    if (err) throw err;
    // 获取json数据并修改项目名称和版本号
    const _data = JSON.parse(data.toString());
    // 修改package的name名称
    _data.name = projectName;
    const str = JSON.stringify(_data, null, 4);
    // 写入文件
    fs.writeFile(`${path}/package.json`, str, function (err) {
      if (err) throw err;
    });
    spinner.text = `下载完成, 正在自动安装项目依赖...`;
    install({ projectName });
  });
};

// 下载模板
const downloadTemplate = (params: { repository: string; projectName: string }) => {
  const { repository, projectName } = params;
  download(repository, projectName, (err) => {
    if (!err) {
      editPackageInfo({ projectName });
    } else {
      console.log(err);
      spinner.stop(); // 停止
      console.log(chalk.red('拉取模板出现未知错误'));
    }
  });
};

// 定义问题列表
const questions = [
  {
    type: 'input',
    name: 'projectName',
    message: '项目文件夹名称:',
    validate(val?: string) {
      if (!val) {
        // 验证一下输入是否正确
        return '请输入文件名';
      }
      if (fs.existsSync(val)) {
        // 判断文件是否存在
        return '文件已存在';
      } else {
        return true;
      }
    }
  },
  {
    type: 'list',
    name: 'template',
    choices: template,
    message: '请选择要拉取的模板'
  }
];

inquirer.prompt(questions).then((answers) => {
  // 获取答案
  const { template: templateUrl, projectName } = answers;
  spinner.start();
  spinner.color = 'green';
  // 开始下载模板
  downloadTemplate({
    repository: templateUrl,
    projectName
  });
});

inquirer这个工具可以帮助我们引导用户在命令行做出“输入/选择等”的操作,但是前提我们定义了一个数组,这个数组就是questions,模板内容存储到了一个公开的仓库(不同类型的模板,分为不同的分支),我们通过download-git-repo这个包去进行下载。模板通过这个repo这个包下载完毕之后,我们调用editPackageInfo去更改packagejson中的信息(比如项目名称);之后我们就可以自动帮助用户去下载依赖,install方法主要就是通过node去运行安装的命令,这里非常简单,就不做过多描述。

而这里的chalk依赖主要是帮助我们去做命令行上的一些色彩显示,比如警告,错误,成功,我们都可以去打印出好看好玩的颜色,让整个命令行的ui变得高大上。
ora这个依赖则帮助我们去做了loading,以及关键文案的提示。

这样我们就完成了一个带有拉取模板功能的命令行工具,我们再次运行create命令,就能正常运行了。

上传之前的准备

在文章的开始我们提到了一个小故事,并且总结了几个开发的问题,我们开发完毕的命令行工具仅仅解决了初始化项目时间成本的问题,但是剩余的三个问题一个也没得到解决,而这一part才是真正实用的东西,在开始我们安装了非常多的依赖,比如eslint以及husky,lint-staged等等,我们这一part主要就是使用这几个依赖打造一个标准的git提交流程,让多人协作变得稳重和统一。

而我们刚刚使用的命令行工具,其中拉取的模板,也同样是这样的校验流程。

我们先从大家最熟悉的eslint开始配置,eslint的配置其实每个团队有自己的方案,但是大多数默认还是用eslint官方的默认配置,所以我这就不做过多花里胡哨的配置。

.eslintrc.js

module.exports = {
  root: true,
  env: { browser: true, node: true },
  parser: '@typescript-eslint/parser',
  parserOptions: {
    ecmaVersion: 2020,
    sourceType: 'module'
  },
  plugins: ['@typescript-eslint'],
  extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'],
  rules: {
    // 支持ts-ignore
    '@typescript-eslint/ban-ts-ignore': 'off',
    '@typescript-eslint/no-var-requires': 0
  }
};

这是一个相对标准的ts工程的eslint,非常适合我们命令行项目,所以我就直接拿过来用了。

这个工程我们需要注意,我们在之后会用到prettier这个美化插件,我们这边配置的extends中有 plugin:prettier/recommended ,因为可能eslint和prettier有冲突,我们这里以prettier为优先,所以不要动它们两个插件的引用顺序。

.eslintignore

node_modules
dist

在现有的package.json中我们也写入了一个命令

"lint": "eslint 'src/**/*' --fix",

这个时候我们尝试一下运行命令,它就能帮助我们自动校验以及fix修复。

除此之外我们在package.json中还定义了一个prettier命令,我们直接运行,就可以发现所有的代码都被美化了格式了。

我们同样可以定义prettier的配置文件以及ignore

.prettierrc

{
  "singleQuote": true,
  "trailingComma": "none",
  "printWidth": 160,
  "tabWidth": 2,
  "tabs": false,
  "semi": true,
  "quoteProps": "as-needed",
  "bracketSpacing": true,
  "jsxBracketSameLine": true,
  "htmlWhitespaceSensitivity": "ignore",
  "useTabs": false,
  "jsxSingleQuote": false,
  "arrowParens": "always",
  "rangeStart": 0,
  "proseWrap": "always",
  "endOfLine": "lf"
}

.prettierignore

dist/
node_modules
*.log

这个时候你可能会问,不仅仅是代码,我们团队不同的ide,空格和tab等等都有差异这该怎么办? 我们可以在根目录定义

.editorconfig

# Editor configuration, see http://editorconfig.org

# 表示是最顶层的 EditorConfig 配置文件
root = true

[*] # 表示所有文件适用
charset = utf-8 # 设置文件字符集为 utf-8
indent_style = space # 缩进风格(tab | space)
indent_size = 2 # 缩进大小
end_of_line = lf # 控制换行类型(lf | cr | crlf)
trim_trailing_whitespace = true # 去除行首的任意空白字符
insert_final_newline = true # 始终在文件末尾插入一个新行

[*.md] # 表示仅 md 文件适用以下规则
max_line_length = off
trim_trailing_whitespace = false

我们可以通过这个小小文件来约束每一个编辑器编写出来的代码特征,甚至在个别ide,我们可以去做自定义的配置,比如说vscode,我可以在根目录新建

.vscode/settings.json

{
  "npm-scripts.showStartNotification": false,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "typescript.preferences.importModuleSpecifier": "relative",
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  }
}

这个配置完成之后,我们可以用到里面一个很实用的功能,就是保存自动eslint fix修复,我们再回到vscode里面,就不用每次保存都需要手动格式化代码啦。

我们现在已经完成了代码统一和美化,以及ide的统一的工作了,现在应该要着手git了,我们在项目提交git之前,需要做一次lint+prettier确保远程仓库的代码统一标准。

那么如何去在提交之前去做校验逻辑呢?husky就可以帮助我们做到这些,我们尝试运行这个命令:

1. husky install
2. npx husky add .husky/pre-commit "npm run lint-staged"

执行完毕之后会在根目录出现一个husky文件夹,其中有一个pre-commit的文件,在这个shell中我们可以看到npm run lint-staged的命令

而在文章开始的package.json中就已经定义好了这一段代码:

 "lint-staged": {
    "*.{ts,js,json,markdown}": [
      "prettier --write",
      "eslint --fix",
      "git add"
    ]
  },

那么lint-staged是什么呢?我们在工程中,通过husky拦截到了git的commit事件,这个时候如果进行一次lint和美化那么是全局意义上的操作,这是非常耗费时间和性能的,所以我们只需要对git的暂存区的文件做lint就可以了,而lint-staged就可以帮助我们去对暂存区的文件进行一系列操作的工具。我们在package.json中就定义了如果暂存区的文件是ts,js,json,markdown的话就要依次执行下面的三个命令。

那么到这里为止,我们的代码就开发完毕了,我们已经成功的完成了自动校验,以及拦截,美化,ide统一/加强。我们开发的命令行工具虽然只有一个拉取模板的小功能但是确实解决了大部分团队的痛点问题,文章开头提到的问题也一一得到了解决。但是我觉得还不够,要在上一点好玩的东西....

GithubAction自动发布到NPM

我们开发出来的命令行工具势必要发布在npm中的,或者是你们团队npm私库都是要易于管理。所以我们将这个工程新建一个dev分支,我们在dev上面开发,当合并到master之后,就自动触发一个action,帮助我们自动发布...

但是在准备之前我们需要在工程的根目录新建

.npmignore

src/
etsc.config.js
tsconfig.json
nodemon.json
typeings/
.*

我们把相关ts,开发文件进行屏蔽。

接着我们去写workflows (新建)

.github/workflows/npm-publish.yml

name: npm-publish
on:
  push:
    branches:
      - master # Change this to your default branch
jobs:
  npm-publish:
    name: npm-publish
    runs-on: ubuntu-latest
    steps:
      - name: 切换仓库
        uses: actions/checkout@master
      - name: 准备Node环境
        uses: actions/setup-node@master
        with:
          node-version: 12.13.0
      - name: 安装依赖以及编译
        run: npm i && npx etsc
      - name: 推送到NPM
        uses: JS-DevTools/npm-publish@v1
        with:
          token: ${{ secrets.NPM_AUTH_TOKEN }}

我们推送到NPM用到了JS-DevTools/npm-publish@v1这个action,这个action好像是最火的推送npm的action,使用方式也非常的简单。我们只需要把本地的package.json的version管理好就可以了,然后我们需要去npm官网,去新建一个token:

WX20210905-195041.png

生成之后的token我们拿到存此工程的仓库去设置:

WX20210905-195236.png

生成的token名称就是: NPM_AUTH_TOKEN

这个时候我们把代码提交到master主分支,就可以发现action已经在运行了,稍等片刻就已经构建完成了。然后npm的包也发布了~

WX20210905-195458.png

enjoy-project-tool npm包

结束

这篇文章说实话算水文了,用到的技术也很简单,希望如果团队还没有这种东西的话就自己着手尝试一下,这个东西很简单,顶多一个早晨的时间就写完了,但是能换来团队很大的便利,这就很值得了。抛砖引玉,我估计有很多用在线的校验平台,其实道理都差不多,我们今天写的这个算是丐版(但是我依然觉得做本地的校验非常有必要)。我把tool和对应的项目模板开源了,会一直维护这个项目。不管是公司用还是自己用,都是非常方便(嘻嘻)

关注公众号老铁们,求你们了~~

今天想到很多内容,想简单写出来,主要是关于今后发展方向的,我从事前端工作2年半左右了,学的东西算是比较多的,但是我想找一个稍微明确的方向,我这里以2/3年后新加坡或者国内一线大厂的高级工程师的水平衡量。


前端分好几个领域,其中有一个领域就是应用实现层,这也是国内大部分前后端做的事情;前端举例子,你只需要会js,css,所谓的几个ui框架或者视图库你就可以完成需求。如果你初具工程化思想,那么你也会去着手设计一些组件,用到一些第三方技术,比如说lit或者ejs等等周边的插件或者工具。还有应用实现层其他的方面,小程序/轻应用/native平台,平台不同,技术也有不同。但是都脱离不了“应用实现”。我一直想摆脱这一层束缚,之前就经常看到知乎或者微信群都在讨论初级工程师和中级工程/高级工程师有什么区别类似的问题时,我就自己默默给我自己划分;现在看来还是很傻的表现,一个程序员的技术不仅仅要看你的编码能力,还有工程能力,架构能力,沟通能力甚至产品运营能力。

跑题了,继续说领域相关的事情。

纯表现层就不说了,这个从字面意思都懂,就是css和canvas类似的特效视觉。

应用架构层,一听这个名字就经常和大神程序员联想在一起,每一个程序员的终极梦想就是架构师/cto,架构层非常考验一个开发人员的广度和深度,在这个领域的程序员你能看到大多数都是年龄比较大的,92后,80后居多。编译原理,算法这些cs的东西将会派上用场,你将会去制定规范,底层优化,应用架构,框架架构等等,解决的是真正的技术问题。这个领域我现在涉及的太浅太浅,但是仍然在往这个方向去靠拢。

基础设施层,这两年尤其是字节在各个大会分享的东西,基础设施占大部分,且非常重头戏。通常这个领域的程序员你必定是在大公司见得多。在这一层就有关,自动化构建,加载,性能优化,代码质量,如何做帮助开发的工具或者平台等等。而我目前也是接触的非常浅的一层,自动化构建和性能优化以及代码质量,都是小团队或者个人的小打小闹。但是确实是在做,没有好的环境条件,做基建很难做。

我的专业之外,我在学习ruby和一部分的node以及serverless这种东西,还有运维相关的等等,包括组建开源团队,从小事做起慢慢做大,去做产品,做运营,做前后端导致我方向现在有点纠结说实话,但是写这篇文章我就能想通了。首先我不忘初心,前端仍然是我主打,去朝架构->基础设施全面发展,英语一定要学好....

晚安睡觉咯

前言

某个普通的一天的早晨,水友群的小姐姐和我聊前端架构,因为她们组最近要筹备一些新项目,在做架构的中途出现了很多问题,所以我拿到了她们的架构项目脚手架代码。拿到代码之后我发现深圳那边的前端团队普遍做的很好,有先进的架构思想,也把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的语义明显拗口。