继续浏览精彩内容
慕课网APP
程序员的梦工厂
打开
继续
感谢您的支持,我会继续努力的
赞赏金额会直接到老师账户
将二维码发送给自己后长按识别
微信支付
支付宝支付

NestJS项目中使用Effect库增强代码质量详解

陪伴而非守候
关注TA
已关注
手记 395
粉丝 62
获赞 285

前言

Effect(以前称为 Effect-TS)通过提供自定义类型和函数,增强 Typescript 的功能,这些类型和函数从如 Scala 中的 ZIO 这样的框架中的函数式编程(FP)获得启发。尽管它最初受到了 FP 的启发,但 Effect 旨在解决 Typescript 实际中存在的问题,并弥补标准库中的缺失功能,这些功能可以在像 NestJS 这样的面向对象编程框架中使用,就像我们在本文中将看到的那样。

说明:本文假定读者已经有一定 NestJS 和 Effect 的基础。我们将重点放在如何有效整合这些技术,而不是深入探讨特定框架的细节。

除了本文中的示例外,整个源代码也可以在这里找到 here

设置

首先,我们创建一个新的项目来试试这个。

使用 nest CLI 来创建一个项目

    $ nest new my-nest-effect-app

然后用你喜欢的包管理器安装 effect

    $ npm install effect

运行此命令来安装effect模块

那么,让我们修改 TypeScript 配置文件内容,如果还没有设置的话,添加 strict: true 这一行。

    // tsconfig.json
    {
      "compilerOptions": {
        // ...其他属性
        "strict": true // 这很重要
      }
    }

最后,我们可以删除今天暂时用不到的文件。

  • test 目录,
  • app.controller.spec.ts 文件,
  • app.service.ts 文件,
  • app.controller.ts 文件,
原流

我们将先使用嵌套模式创建一个基本的过程,接着使用Effect进行重构该过程。

要做到这一点,我们需要在 src 文件夹中创建一个名为 modules 的文件夹,然后在 modules 文件夹内创建一个名为 cat 的文件夹。

    ...其他文件...  
    main.ts  
    package.json  
    src/  
      - modules/ // 新增的文件夹  
        - cat/ // 新的文件夹

然后我们将创建 cat.module.tscat.controller.ts 文件,如下。

    // cat.controller.ts

    @Controller('cats') // 控制猫的控制器
    export class CatController { // 猫控制器类
      constructor(private readonly catService: CatService) {} // 构造函数,传入一个唯一的CatService实例

      @Get() // 获取所有猫的信息
      getCats(): Cat[] {  
        return this.catService.getCats();  
      }  

      @Post() // 创建一只猫
      createCat(@Body() catDto: {name: string}): string {  
        return this.catService.createCat(catDto.name);  
      }  

      @Get(':id') // 根据ID获取一只猫的信息
      getCat(@Param('id') id: string): Cat {  
        return this.catService.getCat(id);  
      }  
    }  

    // cat.module.ts // 猫模块

    @Module({ // 模块定义
      controllers: [CatController], // 控制器列表
      providers: [], // 服务列表
    })  
    export class CatModule {} // 猫模块类

如你所见,我们将用一个Cat域来进行测试,该域包含3个不同的路由,以测试流程的顺畅。

  • 获取 /cats
  • POST /cats
  • 获取 /cats/{id}

最后,我们将创建 cat.service.tscat.type.ts 这两个文件。

    // cat.type.ts  

    export interface Cat {  
      id: string;  
      name: string;  
    }  

    // cat.service.ts  

    @Injectable()  
    export class CatService {  
      db: Map<string, Cat> = new Map(); // 模拟外部的数据库  

      getCats(): Cat[] {  
        return Array.from(this.db.values());  
      }  

      createCat(name: string): string {  
        const id = this.db.size + 1;  

        const newCat = {  
          id: id.toString(),  
          name,  
        };  

        this.db.set(newCat.id, newCat);  

        return newCat.id;  
      }  

      getCat(id: string): Cat {  
        const cat = this.db.get(id);  

        if (!cat) {  
          throw new Error("找不到这只猫咪");  
        }  

        return cat;  
      }  
    }

不要忘记在 cat.module.ts 中导入 cat.service.ts,然后在 app.module.ts 中再导入 cat.module.ts

你的 modules/cat 文件夹应该看起来像这样

    modules/
      - cat/
        - cat.controller.ts,
        - cat.service.ts,
        - cat.type.ts,
        - cat.module.ts

现在我们已经设置好了整个流程,我想明确具体的问题是什么,通过利用效果可以解决这些问题,哪怕是在这么简单的流程里。

首先,我们来看看cat.service.ts文件。这里有两个地方需要注意,一个在getCat方法里,另一个在createCat方法里。我们仔细看看。

getCat 方法

    getCat(id: string): Cat {  
        const cat = this.db.get(id);  

        if (!cat) {  
          throw new Error("找不到这只猫了"); // 这里会抛出错误  
        }  

        return cat;  
    }

在这里,当我们找不到猫时,会抛出一个明确的异常。但由于TS没有提供任何Either/Result类型来让我们指定此方法将返回一个 Cat 或一个 Error,当我们在这个方法被使用的地方,比如在控制器内部时,我们可能不会注意到这一点,忽略它,或者认为这个方法不会出错。

实现猫咪的函数

     createCat(name: string): string {  
        const id = this.db.size + 1;  

        const newCat = { // 注意:缺少验证  
          id: id.toString(),  
          name,  
        };  

        this.db.set(newCat.id, newCat); // 直接使用新猫的ID和对象设置  

        return newCat.id;  
      }

这个 createCat 方法缺乏验证。通常我们在数据库插入之前或过程中会有一个验证步骤。使用纯 TS,我们无法轻易验证新的猫对象。用户可能成功输入一个无效的名字,无论是值还是类型,这可能会引发错误或损害数据。

那怎么用Effect来搞定呢?

效果流

让我们把 cat.service.ts 文件改成使用 Effect。

首先,我们将这样修改 getCat 方法。

    // 之前
    getCat(id: string): Cat {  
      const cat = this.db.get(id);  

      if (!cat) {  
        throw new Error("找不到猫了");  
      }  

      return cat;  
    }  

    // 之后  
    getCat(id: string): Effect.Effect<Cat, Error> { // 新的返回类型,包含Effect  
      const cat = this.db.get(id);  

      return Effect.fromNullable(cat); // 将可能为空的值转换为Effect  
    }

这里有两点改动。

  1. 将返回类型更改为 Effect.Effect<Cat, Error> 类型,这种类型明确地返回可能的结果和可能的错误。
  2. 使用 Effect.fromNullable 函数将可能为 null(null 或 undefined)的值转换为 Effect 对象。

这两个变化大大改善了开发体验和维护的便利性

现在的 createCat,

    // 之前
    createCat(name: string): string {
      const id = this.db.size + 1;

      const newCat = {
        id: id.toString(),
        name,
      };

      this.db.set(newCat.id, newCat);

      return newCat.id;
    }

    // 之后
    createCat(name: string): Effect.Effect<string, ParseError> { // 明确值的类型和可能的解码错误
      return Effect.gen(this, function* () { // 创建生成器函数返回一个效果
        const id = this.db.size + 1;

        const newCat = {
          id: id.toString(),
          name,
        };

        const cat = yield* Schema.decode(Cat)(newCat); // 使用Schema Cat解码对象,如果解码失败则抛出错误

        this.db.set(cat.id, cat);

        return cat.id;
      });
    }

在这里,我们用 Effect 架构模式 来解码 newCat 对象。若失败,它会自动返回一个 解析错误

要做到这一点,我们还需要将我们的猫类型(Cat Type)改为使用 Effect Schema(效果模式) 而不是普通的 TS。

    // cat.type.ts

    const _Cat = Schema.Struct({
      id: Schema.String,
      name: Schema.String,
    });

    export interface Cat extends Schema.Schema.Type<typeof _Cat> {} // 这样使用TS类型就像之前一样

    export const Cat: Schema.Schema<Cat> = _Cat; // 这样定义的Schema类型将用于解码

注意,我不会详细解释Effect的实现(如Schema.Struct,Generator函数等),我建议你直接查看官方文档,那里已经解释得很清楚了,你可以直接参考

现在我们解决了这些问题,让我们通过用一个使用Effect的新方法替换原来的getCats方法来完善我们的catService,以下是新的文件。

    @Injectable()  
    export class CatService {  
      db: Map<string, Cat> = new Map();  

      getCats(): Effect.Effect<Cat[]> {  
        return Effect.succeed(Array.from(this.db.values()));  
      }  

      createCat(name: string): Effect.Effect<string, ParseError> {  
        return Effect.gen(this, function* () {  
          const id = this.db.size + 1;  

          const newCat = {  
            id: id.toString(),  
            name,  
          };  

          const cat = yield* Schema.decode(Cat)(newCat);  

          this.db.set(cat.id, cat);  

          return cat.id;  
        });  
      }  

      getCat(id: string): Effect.Effect<Cat, Error> {  
        const cat = this.db.get(id);  

        return Effect.fromNullable(cat);  
      }  
    }

我们还可以更换控制器,使其更贴合服务方法返回的类型。

@Controller('cats')  
// 控制器类,用于处理与猫咪相关的请求
export class CatController {  
  constructor(private readonly catService: CatService) {}  
  // 构造函数,接收一个不可更改的CatService实例

  @Get()  
  // 获取所有猫咪信息的HTTP GET请求处理方法
  getCats(): Effect.Effect<Cat[]> {  
    // 返回所有猫咪的信息列表
    return this.catService.getCats();  
  }  

  @Post()  
  // 创建新猫咪的HTTP POST请求处理方法
  createCat(@Body() catDto: {name: string}): Effect.Effect<string, ParseError> {  
    // 接收一个对象,其中包含猫咪的名字,并创建一个新猫咪
    return this.catService.createCat(catDto.name);  
  }  

  @Get(':id')  
  // 根据ID获取单个猫咪信息的HTTP GET请求处理方法
  getCat(@Param('id') id: string): Effect.Effect<Cat, Error> {  
    // 根据ID返回单个猫咪的信息
    return this.catService.getCat(id);  
  }  
}
// 注释:"@Body()", "@Param()", "@Get()", "@Post()" 是装饰器,用于定义HTTP请求处理方法
// "Effect.Effect" 表示一个效果或异步操作的结果,可能包含成功或错误信息
// "ParseError" 表示解析错误,可能在数据解析过程中出现
// "Error" 表示一般错误,用于处理可能出现的各种异常情况

我们快完成了,但 nest 中一个重要特性是管道,尤其是非常常见的验证管道。让我们来看看如何用 Effect 实现它。

在我们项目的 app.controller.ts 文件中,有一个名为 createPost 的方法,可能需要一个合适的 Dto 对象。

                      // 使用一个新的 CatDto 类型  
                      //              |  
      @Post()         //              V  
      createCat(@Body() catDto: {name: string}): Effect.Effect<string, ParseError> {  
        return this.catService.createCat(catDto.name);  
      }

咱们来创建一个 cat.dto.ts 技术文件

    // 通过使用类模式,您可以充分利用所有装饰器功能  
    export class CatDto extends Schema.Class<CatDto>('CatDto')({  
      name: Schema.String,  
    }) {}

我们现在可以在控制器里可以导入它了。

(Note: After applying the expert suggestions, "了" is retained twice to accurately reflect both suggestions, but typically, consecutive "了"s are not used. Therefore, a more polished version would be "我们现在可以在控制器里导入它了。")

最终优化后的翻译应为:
我们现在可以在控制器里导入它了。

@Post()  
createCat(@Body() catDto: CatDto): Effect.Effect<string, ParseError> {  
  // 创建一只猫,接收一个包含猫名称的对象,并返回一个效果
  return this.catService.createCat(catDto.name);  
  // 调用catService的createCat方法,传入猫的名字
}

最后,我们将创建一个自定义的 Pipe 来验证 Dto,并在新文件中实现它。

src 文件夹中创建一个 shared 文件夹,并在里面创建一个 effect-validation.pipe.ts 文件。

    @Injectable()  
    export class EffectValidationPipe implements PipeTransform {  
      transform(value: any, metadata: ArgumentMetadata) {  
        // metadata.metatype 相当于 @Body() 或 @Param 参数中给定的类型  
        if (Schema.isSchema(metadata.metatype)) {   
          // 类似于使用 Schema.decode(CatDto)(value)  
          return new metadata.metatype(value);   

        }  

        return value;  
      }  
    }

就这样。如果用户输入不符合Dto,这会自动抛出错误,还会移除多余的键,只保留Dto模式中指定的那些键。

请注意,此 ValidationPipe 只是一个示例,可能不足以应对复杂的验证和类型检查。

不要忘记导入这个验证管道,要么一个一个地应用到每个路由上,要么全局应用它。

@Module({
  imports: [CatModule],
  controllers: [],
  providers: [
    {
      provide: APP_PIPE,
      useClass: EffectValidationPipe,
    },
  ],
})
export class AppModule {}

// @Module 表示这是一个模块定义,用于组织应用的不同部分。
// imports: [CatModule] 表示引入 CatModule 模块。
// controllers: [] 表示不包含任何控制器。
// providers: [] 中定义了服务提供者,这里提供了一个 APP_PIPE,其使用 EffectValidationPipe 类来实现。
// APP_PIPE 是一个应用级别的管道,用于处理请求。
// useClass: EffectValidationPipe 表示使用 EffectValidationPipe 类来实现管道。
最后的改变

如果你现在尝试通过调用猫控制器端点来使用该应用,可能会看到一些奇怪的返回,比如这样的情况。

{
    "_id": "退出",
    "_tag": "成功",
    "value": []
}

这是因为我们在返回值给用户之前,并没有正确地执行我们的Effect。确实,在我们指定它执行之前,Effect实际上并不会被运行

在我们的应用中,做这件事的最佳地点是在拦截器里。我们在shared文件夹里创建一个新文件,比如叫做effect.interceptor.ts

    @Injectable()  
    export class EffectInterceptor implements NestInterceptor {  
      intercept(  
        context: ExecutionContext,  
        next: CallHandler<any>,  
      ): Observable<any> | Promise<Observable<any>> {  
        return next  
          .handle()  
          .pipe(map((data: Effect.Effect<unknown>) => Effect.runPromise(data)));   
          // 在返回数据给用户前先运行效果  
          // 这行代码的作用是在数据返回给用户之前先执行效果  
      }  
    }

然后将这个拦截器要么应用到每个路由上,要么全局应用。

//模块: 定义了模块的元数据
@Module({  
  imports: [CatModule],  
  controllers: [],  
  //提供者: 注册的提供者列表
  providers: [  
    {  
      provide: APP_INTERCEPTOR,  //APP_INTERCEPTOR: 应用拦截器
      useClass: EffectInterceptor,  //使用类: 注册一个类来创建提供者
    },  
    {  
      provide: APP_PIPE,  //APP_PIPE: 应用管道
      useClass: EffectValidationPipe,  
    },  
  ],  
})  
//应用模块: 应用程序的根模块
export class AppModule {}  

就这样 ✨Voila✨,你就可以拥有一个运行 Effect 的 NestJS 项目。

资源:
联系我们

如果你有任何问题或遇到任何错误,可以在评论里留言哦!

你也可以通过以下方式联系我,比如:

打开App,阅读手记
0人推荐
发表评论
随时随地看视频慕课网APP