走近MidwayJS:初识TS装饰器与IoC机制

前言

很惭愧在阿里实习将近三个月没有一点文章产出,同期入职的 炽翎炬透 都产出了很多优秀的文章,如不想痛失薪资普调和年终奖?试试自动化测试!(基础篇),不由感慨优秀的人都是有共同点的:善于总结沉淀,并且文笔还好(这点太羡慕了)。入职即将满三个月,也就是说我三个多月没写过文章了。文笔拙劣,还请见谅。html

本篇文章是 MidwayJS 的系列推广文章第一篇,本来我打算直接一篇搞定,作个MidwayJS开发后台应用的教程就行了。可是在提笔前询问过一些同窗,发现即便是已经有工做经验的前端同窗中也有一部分没有了解过TS装饰器相关的知识,对于IoC机制也知之甚少(虽然没学过Java的我一样只是只知其一;不知其二),所以这篇文章会首先讲解IoC机制(依赖注入)TS装饰器相关的知识,力求内容不枯燥,并使各位成功的对MidwayJS来电~前端

MidwayJS简介

MidwayJS目前已经升级到Midway-Serverless体系,这可能会给没接触过Serverless、只是想学习框架自己的你带来一些困扰。你能够先阅读其框架自己文档,来只体验框架自己做为后端应用的能力。git

你可能没有听过Egg,但你必定听过或者使用过Koa/ExpressEgg基于Koa并在其能力上作了加强,奉行**【约定优于配置】**,同时它又能做为一款定制能力强的基础框架,来使得你能基于本身的技术架构封装出一套适合本身业务场景的框架。MidwayJS正是基于Egg,但在Egg的基础上作了一些较大的变更:github

  • 更好的TS支持,能够说写MidwayJS比较舒服的一个地方就是它的TypeScript支持了,好比会做为服务的接口定义会单独存放于interface, 提供的能力强大的装饰器,与TypeORM这种TS支持好的框架协做起来更是愉悦。typescript

  • IoC机制的路由,以咱们下篇文章将要实现的接口为例:编程

    @provide()
    @controller('/user')
    export class UserController {
    
      @get('/all')
      async getUser(): Promise<void> {
        // ...
      }
    
      @get('/uid/:uid')
      async findUserByUid(): Promise<void> {
        // ...
      }
    
      @post('/uid/:uid')
      async updateUser(): Promise<void> {
        // ...
      }
      
      // ...
    
    }
    复制代码

    (Midway同时保留了Egg的路由能力,即src/app/router.ts的路由配置方式)json

    这里是否会让你想到NestJS?的确在路由这里两者的思想基本是相同的,但Midway的IoC机制底层基于 Injection,一样是Midway团队的做品。而且,Midway的IoC机制也是Midway-Serverless能力的重要支持(这个咱们下篇文章才会讲到)。后端

  • 生态复用,Egg与Koa的中间件大部分能在Midway应用中完美兼容,少部分暂不支持的也由官方团队在快速兼容。设计模式

  • 稳定支持,MidwayJS至今仍在快速发展迭代,同时也在阿里内部做为Serverless基建的重要成员而受到至关的重视,因此你不用担忧它后续的维护状况。babel

下面的部分里,咱们会讲解这些东西:

  • TS装饰器 基本语法、类型
  • Reflect 元编程
  • IoC机制与依赖注入(Dependence Injection)
  • 实现简单的基于IoC的路由
  • 经常使用依赖注入工具库

TS 装饰器

TS装饰器的那些事儿

首先咱们须要知道,JS与TS中的装饰器不是一回事,JS中的装饰器目前依然停留在 stage 2 阶段,而且目前版本的草案与TS中的实现差别至关之大(TS是基于初版,JS目前已经第三版了),因此两者最终的装饰器实现必然有很是大的差别。

其次,装饰器不是TS所提供的特性(如类型、接口),而是TS实现的ECMAScript提案(就像类的私有成员同样)。TS实际上只会对stage-3以上的语言提供支持,好比TS3.7.5引入了可选链(Optional chaining)与空值合并(Nullish-Coalescing)。而当TS引入装饰器时(大约在15年左右),JS中的装饰器依然处于 stage-1 阶段。其缘由是TS与Angular团队PY成功了,Ng团队再也不维护 AtScript,而TS引入了注解语法(Annotation)及相关特性。

可是并不须要担忧,即便装饰器永远到达不了stage-3/4阶段,它也不会消失的。有至关多的框架都是装饰器的重度用户,如AngularNestMidway等。对于装饰器的实现与编译结果会始终保留,就像JSX同样。若是你对它的历史与发展方向有兴趣,能够读一读 是否应该在production里使用typescript的decorator?(贺师俊贺老的回答)

为何咱们须要装饰器?在后面的例子中咱们会体会到装饰器的强大与魅力,基于装饰器咱们可以快速优雅的复用逻辑提供注释通常的解释说明效果,以及对业务代码进行能力加强。同时咱们本文的重点:依赖注入也能够经过装饰器来很是简洁的实现。如今咱们可能暂时体会不到 强大简洁 这些关键词,不急,安心读下去。我会尝试经过这篇文章让你对TS装饰器总体创建起一个认知,并在平常开发里也爱上使用装饰器。

装饰器与注解

因为我自己并没学习过Java以及Spring IoC,所以个人理解可能存在一些误差,还请在评论区指出错误之处~

装饰器与注解实际上也有必定区别,因为并无学过Java,这里就不与Java中的注解进行比较了。而只是说我所认为的两者差别:

  • 注解 应该如同字面意义同样, 只是为某个被注解的对象提供元数据(metadata)的注入,本质上不能起到任何修改行为的操做,须要scanner去进行扫描得到元数据并基于其去执行操做,注解的元数据才有实际意义。
  • 装饰器 无法添加元数据,只能基于已经由注解注入的元数据来执行操做,来对类、方法、属性、参数进行某种特定的操做。

但实际上,TS中的装饰器一般是同时包含了这两种效能的,它可能消费元数据的同时也提供了元数据供别的装饰器消费。

不一样类型的装饰器及使用

在开始前,你须要确保在tsconfig.json中设置了experimentalDecoratorsemitDecoratorMetadata为true。

首先要明确地是,TS中的装饰器实现本质是一个语法糖,它的本质是一个函数,若是调用形式为@deco(),那么这个函数应该再返回一个函数来实现调用。

其次,你应该明白ES6中class的实质,若是不明白,推荐阅读个人这篇文章: 从Babel编译结果看ES6的Class实质

类装饰器

function addProp(constructor: Function) {
  constructor.prototype.job = 'fe';
}

@addProp
class P {
  job: string;
  constructor(public name: string) {}
}

let p = new P('林不渡');

console.log(p.job); // fe
复制代码

咱们发现,在以单纯装饰器方式@addProp调用时,无论用它来装饰哪一个类,起到的做用都是相同的,由于其中要复用的逻辑是固定的。咱们试试以@addProp()的方式来调用:

function addProp(param: string): ClassDecorator {
  return (constructor: Function) => {
    constructor.prototype.job = param;
  };
}

@addProp('fe+be')
class P {
  job: string;
  constructor(public name: string) {}
}

let p = new P('林不渡');

console.log(p.job); // fe+be
复制代码

如今咱们想要添加的属性值就能够由咱们决定了, 实际上因为咱们拿到了原型对象,还能够进行花式操做,可以解锁更多神秘姿式~

方法装饰器

方法装饰器的入参为 类的原型对象 属性名 以及属性描述符(descriptor),其属性描述符包含writable enumerable configurable ,咱们能够在这里去配置其相关信息。

注意,对于静态成员来讲,首个参数会是类的构造函数。而对于实例成员(好比下面的例子),则是类的原型对象

function addProps(): MethodDecorator {
  return (target, propertyKey, descriptor) => {
    console.log(target);
    console.log(propertyKey);
    console.log(JSON.stringify(descriptor));

    descriptor.writable = false;
  };
}

class A {
  @addProps()
  originMethod() {
    console.log("I'm Original!");
  }
}

const a = new A();

a.originMethod = () => {
  console.log("I'm Changed!");
};

a.originMethod(); // I'm Original! 并无被修改
复制代码

你是否以为有点想起来Object.defineProperty()? 的确方法装饰器也是借助它来修改类和方法的属性的,你能够去TypeScript Playground看看TS对上面代码的编译结果。

属性装饰器

相似于方法装饰器,但它的入参少了属性描述符。缘由则是目前没有方法在定义原型对象成员同时去描述一个实例的属性(建立描述符)。

function addProps(): PropertyDecorator {
  return (target, propertyKey) => {
    console.log(target);
    console.log(propertyKey);
  };
}

class A {
  @addProps()
  originProps: any;
}
复制代码

属性与方法装饰器有一个重要做用是注入与提取元数据,这点咱们在后面会体现到。

参数装饰器

参数装饰器的入参首要两位与属性装饰器相同,第三个参数则是参数在当前函数参数中的索引

function paramDeco(params?: any): ParameterDecorator {
  return (target, propertyKey, index) => {
    console.log(target);
    console.log(propertyKey);
    console.log(index);
    target.constructor.prototype.fromParamDeco = '呀呼!';
  };
}

class B {
  someMethod(@paramDeco() param1: any, @paramDeco() param2: any) {
    console.log(`${param1} ${param2}`);
  }
}

new B().someMethod('啊哈', '林不渡!');
// @ts-ignore
console.log(B.prototype.fromParamDeco);

复制代码

参数装饰器与属性装饰器都有个特别之处,他们都不能获取到描述符descriptor,所以也就不能去修改其参数/属性的行为。可是咱们能够这么作:给类原型添加某个属性,携带上与参数/属性/装饰器相关的元数据,并由下一个执行的装饰器来读取。(装饰器的执行顺序请参见下一节)

固然像例子中这样直接在原型上添加属性的方式是十分不推荐的,后面咱们会使用ES7的Reflect Metadata来进行元数据的读/写。

装饰器工厂

假设如今咱们同时须要四种装饰器,你会怎么作?定义四种装饰器而后分别使用吗?也行,但后续你看着这一堆装饰器可能会感受有点头疼...,所以咱们能够考虑接入工厂模式,使用一个装饰器工厂来为咱们根据条件吐出不一样的装饰器。

首先咱们准备好各个装饰器函数:

(不建议把功能也写在装饰器工厂中,会形成耦合)

// @ts-nocheck

function classDeco(): ClassDecorator {
  return (target: Object) => {
    console.log('Class Decorator Invoked');
    console.log(target);
  };
}

function propDeco(): PropertyDecorator {
  return (target: Object, propertyKey: string) => {
    console.log('Property Decorator Invoked');
    console.log(propertyKey);
  };
}

function methodDeco(): MethodDecorator {
  return ( target: Object, propertyKey: string, descriptor: PropertyDescriptor ) => {
    console.log('Method Decorator Invoked');
    console.log(propertyKey);
  };
}

function paramDeco(): ParameterDecorator {
  return (target: Object, propertyKey: string, index: number) => {
    console.log('Param Decorator Invoked');
    console.log(propertyKey);
    console.log(index);
  };
}
复制代码

接着,咱们实现一个工厂函数来根据不一样条件返回不一样的装饰器:

enum DecoratorType {
  CLASS = 'CLASS',
  METHOD = 'METHOD',
  PROPERTY = 'PROPERTY',
  PARAM = 'PARAM',
}

type FactoryReturnType =
  | ClassDecorator
  | MethodDecorator
  | PropertyDecorator
  | ParameterDecorator;

function decoFactory(type: DecoratorType, ...args: any[]): FactoryReturnType {
  switch (type) {
    case DecoratorType.CLASS:
      return classDeco.apply(this, args);

    case DecoratorType.METHOD:
      return methodDeco.apply(this, args);

    case DecoratorType.PROPERTY:
      return propDeco.apply(this, args);

    case DecoratorType.PARAM:
      return paramDeco.apply(this, args);

    default:
      throw new Error('Invalid DecoratorType');
  }
}

@decoFactory(DecoratorType.CLASS)
class C {
  @decoFactory(DecoratorType.PROPERTY)
  prop: any;

  @decoFactory(DecoratorType.METHOD)
  method(@decoFactory(DecoratorType.PARAM) param: string) {}
}

new C().method();
复制代码

(注意,这里在TS类型定义上彷佛有些问题,因此须要带上顶部的@ts-nocheck,在后续解决了类型报错后,我会及时更新的TAT)

多个装饰器声明

装饰器求值顺序来自于TypeScript官方文档一节中的装饰器说明。

类中不一样声明上的装饰器将按如下规定的顺序应用:

  1. 参数装饰器,而后依次是方法装饰器访问符装饰器,或属性装饰器应用到每一个实例成员。
  2. 参数装饰器,而后依次是方法装饰器访问符装饰器,或属性装饰器应用到每一个静态成员。
  3. 参数装饰器应用到构造函数。
  4. 类装饰器应用到类。

注意这个顺序,后面咱们可以实现元数据读写,也正是由于这个顺序。

当存在多个装饰器来装饰同一个声明时,则会有如下的顺序:

  • 首先,由上至下依次对装饰器表达式求值,获得返回的真实函数(若是有的话)
  • 然后,求值的结果会由下至上依次调用

(有点相似洋葱模型)

function foo() {
    console.log("foo in");
    return function (target, propertyKey: string, descriptor: PropertyDescriptor) {
        console.log("foo out");
    }
}

function bar() {
    console.log("bar in");
    return function (target, propertyKey: string, descriptor: PropertyDescriptor) {
        console.log("bar out");
    }
}

class A {
    @foo()
    @bar()
    method() {}
}

// foo in
// bar in
// bar out
// foo out
复制代码

Reflect Metadata

基本元数据读写

Reflect Metadata是属于ES7的一个提案,其主要做用是在声明时去读写元数据。TS早在1.5+版本就已经支持反射元数据的使用,目前想要使用,咱们还须要安装reflect-metadata与在tsconfig.json中启用emitDecoratorMetadata选项。

你能够将元数据理解为用于描述数据的数据,如某个对象的键、键值、类型等等就可称之为该对象的元数据。咱们先不用太在乎元数据定义的位置,先作一个简单的阐述:

为类或类属性添加了元数据后,构造函数的原型(或是构造函数,根据静态成员仍是实例成员决定)会具备[[Metadata]]属性,该属性内部包含一个Map结构,键为属性键,值为元数据键值对

reflect-metadata提供了对Reflect对象的扩展,在引入后,咱们能够直接从Reflect对象上获取扩展方法。

文档见 reflect-metadata,但不用急着看,其API命令仍是很语义化的

import 'reflect-metadata';

@Reflect.metadata('className', 'D')
class D {
  @Reflect.metadata('methodName', 'hello')
  public hello(): string {
    return 'hello world';
  }
}

const d = new D();
console.log(Reflect.getMetadata('className', D));
console.log(Reflect.getMetadata('methodName', d));

复制代码

能够看到,咱们给类D与D内部的方法hello都注入了元数据,并经过getMetadata(metadataKey, target)这个方式取出了存放的元数据。

Reflect-metadata支持命令式(Reflect.defineMetadata)与声明式(上面的装饰器方式)的元数据定义

咱们注意到,注入在类上的元数据在取出时target为这个类D,而注入在方法上的元数据在取出时target则为实例d。缘由其实咱们实际上在上面的装饰器执行顺序提到了,这是因为注入在方法、属性、参数上的元数据其实是被添加在了实例对应的位置上,所以须要实例化才能取出。

内置元数据

Reflect容许程序去检视自身,基于这个效果,咱们能够在装饰器运行时去检查其类型相关信息,如目标类型、目标参数的类型以及方法返回值的类型,这须要借助TS内置的元数据metadataKey来实现,以一个检查入参的例子为例:

访问符装饰器的属性描述符会额外拥有getset方法,其余与属性装饰器相同

import 'reflect-metadata';

class Point {
  x: number;
  y: number;
}

class Line {
  private _p0: Point;
  private _p1: Point;

  @validate
  set p0(value: Point) {
    this._p0 = value;
  }
  get p0() {
    return this._p0;
  }

  @validate
  set p1(value: Point) {
    this._p1 = value;
  }
  get p1() {
    return this._p1;
  }
}

function validate<T>( target: any, propertyKey: string, descriptor: TypedPropertyDescriptor<T> ) {
  let set = descriptor.set!;
  descriptor.set = function (value: T) {
    let type = Reflect.getMetadata('design:type', target, propertyKey);
    if (!(value instanceof type)) {
      throw new TypeError('Invalid type.');
    }
    set(value);
  };
}
复制代码

这个例子来自于TypeScript官方文档,但实际上不能正常执行。由于在通过装饰器处理后,set方法的this将会丢失。但我猜测官方的用意只是展现design:type的用法。

在这个例子中,咱们基于Reflect.getMetadata('design:type', target, propertyKey);获取到了装饰器对应声明的属性类型,并确保在setter被调用时检查值类型。

这里的 design:type 便是TS的内置元数据,你能够理解为TS在编译前还手动执行了@Reflect.metadata("design:type", Point)。TS还内置了**design:paramtypes(获取目标参数类型)design:returntype(获取方法返回值类型)**这两种元数据字段来提供帮助。但有一点须要注意,即便对于基本类型,这些元数据也返回对应的包装类型,如number -> [Function: Number]

IoC

IoC、依赖注入、容器

IoC的全称为 Inversion of Control,意为控制反转,它是OOP中的一种原则(虽然不在n大设计模式中,但实际上IoC也属于一种设计模式),它能够很好的解耦代码。

在不使用IoC的状况下,咱们很容易写出来这样的代码:

import { A } from './modA';
import { B } from './modB';

class C {
  constructor() {
    this.a = new A();
    this.b = new B();
  }
}
复制代码

乍一看可能没什么,但实际上类C会强依赖于A、B,形成模块之间的耦合。要解决这个问题,咱们能够这么作:用一个第三方容器来负责管理容器,当咱们须要某个实例时,由这个容器来替咱们实例化并交给咱们实例。以Injcetion为例:

import { Container } from 'injection';
import { A } from './A';
import { B } from './B';
const container = new Container();
container.bind(A);
container.bind(B);

class C {
  constructor() {
    this.a = container.get('a');
    this.b = container.get('b');
  }
}
复制代码

如今A、B、C之间没有了耦合,甚至当某个类D须要使用C的实例时,咱们也能够把C交给IoC容器。

咱们如今可以知道IoC容器大概的做用了:容器内部维护着一个对象池,管理着各个对象实例,当用户须要使用实例时,容器会自动将对象实例化交给用户。

再举个栗子,当咱们想要处对象时,会上Soul、Summer、陌陌...等等去一个个找,找哪一种的与怎么找是由我本身决定的,这叫 控制正转。如今我以为有点麻烦,直接把本身的介绍上传到世纪佳缘,若是有人看上我了,就会主动向我发起聊天,这叫 控制反转

DI的全称为Dependency Injection,即依赖注入。依赖注入是控制反转最多见的一种应用方式,就如它的名字同样,它的思路就是在对象建立时自动注入依赖对象。再以Injection的使用为例:

// provide意为当前对象须要被绑定到容器中
// inject意为去容器中取出对应的实例注入到当前属性中
@provide()
export class UserService {
 
  @inject()
  userModel;

  async getUser(userId) {
    return await this.userModel.get(userId);
  }
}
复制代码

咱们不须要在构造函数中去手动this.userModel = xxx了,容器会自动帮咱们作这一步。

实例: 基于IoC的路由简易实现

咱们在最开始介绍了MidwayJS的路由机制,大概长这样:

@provide()
@controller('/user')
export class UserController {

  @get('/all')
  async getUser(): Promise<void> {
    // ...
  }

  @get('/uid/:uid')
  async findUserByUid(): Promise<void> {
    // ...
  }

  @post('/uid/:uid')
  async updateUser(): Promise<void> {
    // ...
  }
}
复制代码

@provide()来自于底层的IoC支持Injection,Midway在应用启动时会去扫描被@provide()装饰的对象,并装载到容器中,这里不是重点,能够暂且跳过,咱们主要关注如何将装饰器路由解析成路由表的形式)

咱们要解析的路由以下:

@controller('/user')
export class UserController {
  @get('/all')
  async getAllUser(): Promise<void> {
    // ...
  }

  @post('/update')
  async updateUser(): Promise<void> {
    // ...
  }
}
复制代码

首先思考controllerget/post装饰器,咱们须要使用这几个装饰器注入哪些信息:

  • 路径
  • 方法(方法装饰器)

首先是对于整个类,咱们须要将path: "/user"这个数据注入:

// 工具常量枚举
export enum METADATA_MAP {
  METHOD = 'method',
  PATH = 'path',
  GET = 'get',
  POST = 'post',
  MIDDLEWARE = 'middleware',
}

const { METHOD, PATH, GET, POST } = METADATA_MAP;

export const controller = (path: string): ClassDecorator => {
  return (target) => {
    Reflect.defineMetadata(PATH, path, target);
  };
};
复制代码

然后是方法装饰器,咱们选择一个高阶函数(柯里化)去吐出各个方法的装饰器,而不是为每种方法定义一个。

// 方法装饰器 保存方法与路径
export const methodDecoCreator = (method: string) => {
  return (path: string): MethodDecorator => {
    return (_target, _key, descriptor) => {
      Reflect.defineMetadata(METHOD, method, descriptor.value!);
      Reflect.defineMetadata(PATH, path, descriptor.value!);
    };
  };
};

// 首先肯定方法,然后在使用时才去肯定路径
const get = methodDecoCreator(GET);
const post = methodDecoCreator(POST);
复制代码

接下来咱们要作的事情就很简单了:

  • 拿到注入在类上元数据的根路径
  • 拿到每一个方法上元数据的方法、路径
  • 拼接,生成路由表
const routeGenerator = (ins: Object) => {
  const prototype = Object.getPrototypeOf(ins);

  const rootPath = Reflect.getMetadata(PATH, prototype['constructor']);

  const methods = Object.getOwnPropertyNames(prototype).filter(
    (item) => item !== 'constructor'
  );

  const routeGroup = methods.map((methodName) => {
    const methodBody = prototype[methodName];

    const path = Reflect.getMetadata(PATH, methodBody);
    const method = Reflect.getMetadata(METHOD, methodBody);
    return {
      path: `${rootPath}${path}`,
      method,
      methodName,
      methodBody,
    };
  });
  console.log(routeGroup);
  return routeGroup;
};
复制代码

生成的结果大概是这样:

[
  {
    path: '/user/all',
    method: 'post',
    methodName: 'getAllUser',
    methodBody: [Function (anonymous)]
  },
  {
    path: '/user/update',
    method: 'get',
    methodName: 'updateUser',
    methodBody: [Function (anonymous)]
  }
]
复制代码

基于这种思路,咱们能够很容易的写一个使Koa支持IoC路由的工具。若是你有兴趣,不妨扩展一下。好比说路由还有可能长这样:

@controller('/user', { middleware:[mw1, mw2, ...] })
export class UserController {
  @get('/all', { middleware:[mw11, mw22, ...] })
  async getAllUser(): Promise<void> {
    // ...
  }

  @get('/:uid')
    async getUser(): Promise<void> {
      // ...
    }

  @post('/update')
  async updateUser(): Promise<void> {
    // ...
  }
}
复制代码

新增了几个地方:

  • 全局中间件
  • 路由级别中间件
  • 路由传参

要不要试试整活?

这个例子是否属于IoC机制的体现可能会有争议,但我我的认为Reflect Metadata的设计自己就是IoC的体现。若是你有别的见解,欢迎在评论区告知我。

依赖注入工具库

我我的了解并使用过的TS依赖注入工具库包括:

  • TypeDI,TypeStack出品
  • TSYringe,微软出品
  • Injection,MidwayJS团队出品,是MidwayJS底层IoC的能力支持

其中TypeDI也是我平常使用较多的一个,若是你使用基本的Koa开发项目,不妨试一试TypeORM + TypeORM-TypeDI-Extensions 。咱们再看看上面呈现过的Injection的例子:

@provide()
export class UserService {
 
  @inject()
  userModel;

  async getUser(userId) {
    return await this.userModel.get(userId);
  }
}
复制代码

实际上,一个依赖注入工具库一定会提供的就是 从容器中获取实例注入对象到容器中的两个方法,如上面的provideinject,TypeDI的ServiceInject

总结

读完这篇文章,我想你应该对TypeScript中的装饰器与IoC机制有了大概的了解,若是你意犹未尽,不妨去看一下TypeScript对装饰器、反射元数据的编译结果,见TypeScript Playground。或者,若是你想早点开始了解MidwayJS,在阅读文档的基础上,你也能够瞅瞅我写的这个简单的Demo:Midway-Article-Demo,基于 Midway + TypeORM + SQLite3,但请注意仍处于雏形,许多Midway的强大能力还没有获得体现,因此不要以这个Demo断定Midway的能力,我会尽快完善这个Demo的。

下一篇,咱们会讲解Midway的基础能力,以及对Midway-Serverless:阿里巴巴淘系技术部面对Serverless交出的其中一份答卷的展望。本篇内容可能仍是有些枯燥,下一篇咱们就会进入欢乐的实战环节啦~

相关文章
相关标签/搜索