NestJS实战之开发短链服务(四)

286 阅读13分钟

前言

接上篇,NestJS实战之开发短链服务(三),在上一篇文章中我们已经完成了项目的基础配置,到这一节就开始真正的进行搬砖了,我们将会在这篇文章中阐述对数据库的增删改查,这篇文章知识点对于大家能够得到提升的知识点主要集中在前半部分,后面内容可能不会像之前的文章那样有很多经验性的东西,但是作为短链服务不可或缺的部分,又不得不阐述,如果你没有耐心看完的话,可以先收藏,若真正需要使用NestJS进行开发的时候再进行查阅可能稍好。

服务端部分

在这个部分,对于TypeORM的API我不做过多的解释,因为TypeORM是一个非常独立的章节,大家可以直接查阅TypeORM的相关API较好,我仅向大家阐述一些可能前端日常无法常接触到的一些服务端的概念。

DTO

首先,我们先向大家阐述一下在后端开发中一个常见的概念,DTO(Data Transfer Object),是一种常用于软件开发中的设计模式,用于在不同的软件系统或系统的不同部分之间传输数据。DTO主要用于减少一个系统或应用中数据交换的次数,封装多个数据操作成一个单一的对象,从而简化网络请求或数据库调用,提升性能和可维护性。其实说白了,DTO还是在软件开发中解耦的一种方式。

为什么是这样呢?因为在实际开发中,业务后端可能面临的多个下游的调用,这种时候,他们需要接受的参数可能会让你感觉比较奇怪,有些时候明明我们不需要追加某些参数,但是他们却需要,要求我们传递,正是这个道理。

因此,为了规范起见,我们不直接与数据库的字段相耦合,我们也需要定义自己的DTO,哪怕这些字段和数据库字段一致也没关系,为的就是为将来可能的修改做准备。

规则DTO:

export interface RuleDto {
  // 唯一性标识
  id?: number;  
  /**
   * 短链码
   */
  code: string;
  /**
   * 跳转地址
   */
  targetUrl?: string;
  /**
   * 短链描述
   */
  description?: string;
  /**
   * 是否定义一次性的跳转策略函数
   */
  customStrategyFunc?: string;
  /**
   * 关联的策略ID列表
   */
  strategyIds: number[];

  /**
   * 创建者
   */
  createBy?: string;

  /**
   * 更新者
   */
  updateBy?: string;
}

策略DTO:

export interface StrategyDto {
  // 唯一性标识
  id?: number; 
  /**
   * 策略名称
   */
  name: string;
  /**
   * 策略描述
   */
  description: string;
  /**
   * 策略内容
   */
  func: string;
  /**
   * 创建者
   */
  createBy?: string;
  /**
   * 更新者
   */
  updateBy?: string;
}

数据库的事务

数据库事务是数据库管理系统执行过程中的一个逻辑单位,由一个或多个相关的数据库操作组成。事务在任何数据库管理系统中都是一个重要的概念,它确保数据库在发生故障时仍然保持一致性和完整性。一个事务要么完全执行,要么完全不执行,这是通过数据库管理系统提供的事务管理和数据恢复功能来实现的。

事务具备的四个基本特性:

  • 原子性确保构成事务的所有操作都作为一个整体执行,即事务内的操作要么全部成功,要么全部失败。如果事务中的任何操作失败,则整个事务将回滚到事务开始之前的状态。
  • 一致性保证事务的执行将数据库从一个一致的状态转变到另一个一致的状态。一致性意味着数据库中的数据将完全符合所有预定义的规则,包括数据完整性和业务规则。
  • 隔离性确保并发执行的事务是彼此隔离的,事务对数据的修改在提交之前对其他事务是不可见的。隔离性帮助防止多个事务同时执行时可能出现的数据不一致问题。
  • 持久性确保一旦事务被提交,其结果就是永久的,即使发生系统故障,如数据库崩溃或故障,事务的结果也不会丢失。

我不是一个专业的后端,所以对于事务的一些更深层次的理解欠佳,但是我大致明白一个道理,当我们在一个操作中,需要用到有依赖关系的几个读写操作时,这种情况就必须要用到事务。

比如,有一个场景,我们往数据库插入一条数据的时候,我们需要做一个验证的判断,我们会不假思索的想到,首先数据库里面查询到对应的记录,看看有没有冲突的条件,没有的话,则成功插入,有的话则向前端输出错误,但是理想很丰满,现实很骨感,两个完全一模一样的数据过来了,大家在插入的时候都发现数据表中的记录没有冲突的条件,于是大家都插入成功了,这就造成了系统的bug,而如果我们使用事务来改进这个设计的话,因为事务是原子性的,在插入的时候,永远会保证别的操作暂时在后面先排队着,然后第一个人的数据没有问题,则成功插入,但是第二个人再插入的时候,数据库里面已经有重复的数据了,就无法再插入了,这就规避了bug。

我在本文中,对于事务的阐述就到此结束了,但是对于事务有兴趣的同学可以通过搜索引擎查阅更详细的资料。

利用管道进行数据的验证

在之前关于NestJS的基础原理的讲解中,我们就阐述过管道的最大的用途就是用来做数据的验证。记录我的NestJS探究历程(九)——管道

使用管道做验证的优势相当于是一个通用的入口,可以收敛验证的业务,能够使得我们的代码看起来更紧凑,我们的业务代码处理的内容就不再需要考虑错误的数据了,因此这也算得上是编写高内聚、低耦合代码的一种实践。

在之前的NestJS的基础原理的讲解中,我们已经聊到了,我们的代码都是包在过滤器中的,只要我们在任何地方抛出过滤器能够捕获的错误(之所以这样说,就是因为有些异步错误,全局过滤器捕获不到),都会进到过滤器的逻辑处理。所以在管道的验证过程中,我们只需要抛出有意义的错误即可。

对于管道验证的错误,正常情况应该是http的400(Bad Request)状态码才是符合语义的,不过具体还是得看你们的业务的定义,我在项目里面是抛出的是200,因为只有200的状态码才会走到我正常的提示流程,告诉用户错误所在的位置。

在上一篇文章中,我们已经把项目基础中处理全局异常的逻辑完成了,所以这儿就只需要抛出错误即可。

我就给大家贴一个我的一个自定义策略验证管道的例子:

import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common';
import { StrategyDto } from '../dtos/strategy.dto';
import { errorPredicate } from '@/modules/common/utils';

@Injectable()
export class CreateStrategyDtoValidationPipe implements PipeTransform {
  transform(strategyDto: StrategyDto, metadata: ArgumentMetadata) {
    const name = strategyDto.name ?? '';
    if (name == '') {
      errorPredicate('策略名称不能为空');
    }
    try {
      strategyDto.func !== '' && eval(strategyDto.func);
    } catch (exp) {
      errorPredicate('自定义策略内容不合法');
    }
    return strategyDto;
  }
}

在一会儿,我们编写Controller的时候,我们直接把这个管道关联到HttpMethod的装饰器上即可。

对策略的CRUD

麻雀虽小,五脏俱全,该分层的我们还是按规矩分层,所以,对于这部分,我们还是分为3层,即API层(Controller),业务逻辑层(Service),数据访问层(Repository),但是,这部分,我就没有按照在之前的文章提到的面向接口编程的规范实施了,记录我的NestJS探究历程(二)——编写可维护的代码实践,但是大家必须要知道这个事儿。

Controller

import {
  Body,
  Controller,
  Delete,
  Get,
  HttpCode,
  Inject,
  Post,
  Put,
  Query,
} from '@nestjs/common';
import { StrategyService } from '../services/strategy.service';
import { StrategyDto, UpdateStrategyDto } from '../dtos/strategy.dto';
import { BaseController } from '@/modules/common/controllers/base.controller';
import { CreateStrategyDtoValidationPipe } from '../pipes/create-strategy-validator.pipe';
import { UpdateStrategyDtoValidationPipe } from '../pipes/update-strategy-validator.pipe';

@Controller('/manage')
export class StrategyController extends BaseController {
  @Inject()
  protected strategyService: StrategyService;

  @Get('/strategy')
  @HttpCode(200)
  async getStrategyList(@Query('id') id?: string) {
    let numId: number;
    if (typeof id !== 'undefined') {
      numId = Number.parseInt(id);
    }
    const execResult = await this.strategyService.getStrategyList(numId);
    return this.sendSuccessResponse(execResult);
  }

  @Put('/strategy')
  @HttpCode(200)
  public async createStrategy(
     // 绑定验证管道  
    @Body(CreateStrategyDtoValidationPipe) strategyDto: StrategyDto,
  ) {
    strategyDto.createBy = this.getOperator();
    const execResult = await this.strategyService.createStrategy(strategyDto);
    return this.sendSuccessResponse(execResult);
  }

  @Post('/strategy')
  @HttpCode(200)
  async updateStrategy(
    // 绑定验证管道
    @Body(UpdateStrategyDtoValidationPipe) strategyDto: UpdateStrategyDto,
  ) {
    strategyDto.updateBy = this.getOperator();
    const execResult = await this.strategyService.updateStrategy(strategyDto);
    return this.sendSuccessResponse(execResult);
  }

  @Delete('/strategy')
  @HttpCode(200)
  async deleteStrategy(@Query('id') id: number) {
    const execResult = await this.strategyService.deleteStrategy(id);
    return this.sendSuccessResponse(execResult);
  }
}

Service

对于这个位置,仅仅只需要调一下Repository的方法的操作,大家不要觉得这是在无病呻吟,只不过因为目前的业务形态比较简单,暂时不需要做一些额外的处理,但是将其分离出来,能够很好的应对将来的业务变化,而不至于再去改动Repository层的代码。

import { Inject, Injectable } from '@nestjs/common';
import { StrategyDto, UpdateStrategyDto } from '../dtos/strategy.dto';
import {
  CallTrack,
  IgnoreOutput,
} from '@/modules/common/decorators/call-track.decorator';
import { StrategyRepository } from '../repositories/strategy.repository';

@Injectable()
@CallTrack
export class StrategyService {
  @Inject()
  private strategyRepo: StrategyRepository;

  async createStrategy(strategyDto: StrategyDto): Promise<StrategyEntity> {
    return this.strategyRepo.createStrategy(strategyDto);
  }

  async updateStrategy(strategyDto: UpdateStrategyDto) {
    return this.strategyRepo.updateStrategy(strategyDto);
  }

  @IgnoreOutput
  getStrategyList(id?: number) {
    return this.strategyRepo.getStrategyList(id);
  }

  async getStrategy(id: number) {
    return this.strategyRepo.getStrategy(id);
  }

  async deleteStrategy(id: number) {
    return this.strategyRepo.deleteStrategy(id);
  }
}

Repository

import { Injectable } from '@nestjs/common';
import { Strategy as StrategyEntity } from '@/modules/persistence/entities/strategy';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { StrategyDto, UpdateStrategyDto } from '../dtos/strategy.dto';
import { v4 as uuidv4 } from 'uuid';
import { errorPredicate } from '@/modules/common/utils';
import {
  CallTrack,
  IgnoreOutput,
} from '@/modules/common/decorators/call-track.decorator';

@Injectable()
@CallTrack
export class StrategyRepository {
  @InjectRepository(StrategyEntity)
  private strategyRepo: Repository<StrategyEntity>;

  async createStrategy(strategyDto: StrategyDto): Promise<StrategyEntity> {
    const strategyEntity = new StrategyEntity();
    strategyEntity.description = strategyDto.description;
    // UUID自动生成
    strategyEntity.key = uuidv4(36);
    strategyEntity.func = strategyDto.func;
    strategyEntity.name = strategyDto.name;
    strategyEntity.createdAt = new Date();
    strategyEntity.createdBy = strategyDto.createBy;
    // 新增的时候取一个人
    strategyEntity.updatedBy = strategyDto.createBy;
    // 更新时间取当前
    strategyEntity.updatedAt = new Date();
    // 创建默认不生效
    strategyEntity.isValid = false;
    return this.strategyRepo.save(strategyEntity);
  }

  async updateStrategy(strategyDto: UpdateStrategyDto) {
    const strategyEntity = await this.strategyRepo.findOne({
      where: [{ id: strategyDto.id }],
    });
    if (!strategyEntity) {
      errorPredicate('没有找到需要更新的策略实体');
    }
    Object.entries(strategyDto).forEach(([prop, val]) => {
      strategyEntity[prop] = val;
    });
    strategyEntity.updatedAt = new Date();
    strategyEntity.updatedBy = strategyDto.updateBy;
    return this.strategyRepo.save(strategyEntity);
  }

  @IgnoreOutput
  getStrategyList(id?: number) {
    const builder = this.strategyRepo.createQueryBuilder('strategy');
    if (typeof id === 'number') {
      builder.where('strategy.id = :id', { id });
    }
    return builder.orderBy('strategy.id', 'DESC').getMany();
  }

  async getStrategy(id: number) {
    return this.strategyRepo
      .createQueryBuilder('strategy')
      .where('strategy.id = :id', { id })
      .getOneOrFail();
  }

  async deleteStrategy(id: number) {
    const { affected } = await this.strategyRepo
      .createQueryBuilder()
      .delete()
      .from(StrategyEntity)
      .where('id = :id', { id })
      .execute();
    return {
      total: affected ?? 0,
    };
  }
}

对规则的CRUD

Controller

import {
  Body,
  Controller,
  Delete,
  Get,
  HttpCode,
  Inject,
  Post,
  Put,
  Query,
} from '@nestjs/common';
import { RuleDto } from '../dtos/rule.dto';
import { RuleService } from '../services/rule.service';
import { BaseController } from '@/modules/common/controllers/base.controller';
import { CreateRuleDtoValidationPipe } from '../pipes/create-rule-validator.pipe';
import { UpdateRuleDtoValidationPipe } from '../pipes/update-rule-validator.pipe';

@Controller('/manage')
export class RuleController extends BaseController {
  @Inject()
  ruleService: RuleService;

  @Put('/rule')
  async createRule(@Body(CreateRuleDtoValidationPipe) ruleDto: RuleDto) {
    ruleDto.createBy = this.getOperator();
    const execResult = await this.ruleService.createRule(ruleDto);
    return this.sendSuccessResponse(execResult);
  }

  @Get('/rule')
  @HttpCode(200)
  async getRuleList(
    @Query('pageNum') pageNum: number,
    @Query('pageSize') pageSize: number,
    @Query('code') code: string,
  ) {
    const execResult = await this.ruleService.getRuleList({
      page: pageNum,
      size: pageSize,
      code,
    });
    return this.sendSuccessResponse(execResult);
  }

  @Post('/rule')
  @HttpCode(200)
  async updateRule(
    @Body(UpdateRuleDtoValidationPipe) ruleDto: RuleDto & { id: number },
  ) {
    ruleDto.updateBy = this.getOperator();
    const execResult = await this.ruleService.updateRule(ruleDto);
    return this.sendSuccessResponse(execResult);
  }

  @Delete('/rule')
  @HttpCode(200)
  async deleteRule(@Query('id') id: number) {
    const execResult = await this.ruleService.deleteRule(id);
    return this.sendSuccessResponse(execResult);
  }

  @HttpCode(200)
  @Post('/rule/copy')
  async copyRule(@Body('id') id: number) {
    const createBy = this.getOperator();
    const execResult = await this.ruleService.copyRule(id, createBy);
    return this.sendSuccessResponse(execResult);
  }
}

Service

为了篇幅的考虑,这个位置就不贴代码了,因为仍然只是对Repostory的简单调用而已。

Repository

import { Rule as RuleEntity } from '@/modules/persistence/entities/rule';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { RuleDto } from '../dtos/rule.dto';
import { exposeInt, getShortLinkCode } from '@/modules/common/config';
import {
  CallTrack,
  IgnoreOutput,
} from '@/modules/common/decorators/call-track.decorator';

@Injectable()
@CallTrack
export class RuleRepository {
  @InjectRepository(RuleEntity)
  private ruleRepo: Repository<RuleEntity>;

  /**
   * 根据ID复制一个短链
   * @param id
   */
  async copyRule(id: number, createUser: string) {
    const copyRuleEntity = await this.getRule(id);
    if (!copyRuleEntity) {
      throw new Error('参数错误,无法复制');
    }
    const entityManager = this.ruleRepo.manager;
    return await entityManager.transaction(
      async (transactionalEntityManager) => {
        const ruleEntity = new RuleEntity();
        // 短链码为新建的,先临时插入一个,避免插入不进去
        ruleEntity.code = 'TEMP';
        ruleEntity.createdAt = new Date();
        ruleEntity.createdBy = createUser;
        ruleEntity.description = copyRuleEntity.description;
        ruleEntity.customStrategyFunc = copyRuleEntity.customStrategyFunc;
        // 新复制的链接为不生效
        ruleEntity.isValid = false;
        ruleEntity.updatedAt = new Date();
        ruleEntity.updatedBy = createUser;
        ruleEntity.targetUrl = copyRuleEntity.targetUrl;
        ruleEntity.strategyIds = copyRuleEntity.strategyIds;
        const savedEntity = await this.ruleRepo.save(ruleEntity);
        const lastRuleId = savedEntity.id;
        // 膨胀规则ID
        const ruleId = exposeInt(lastRuleId + 1);
        if (!Number.isSafeInteger(ruleId)) {
          throw new Error('短链转换规则已用尽');
        }
        // 生成短链码
        const tinyUrlCode = getShortLinkCode(ruleId);
        savedEntity.code = tinyUrlCode;
        // 再次保存更新过后的实体
        return await transactionalEntityManager.save(savedEntity);
      },
    );
  }

  /**
   * 分页获取短链规则列表
   * @returns
   */
  @IgnoreOutput
  async getRuleList(
    query: { page?: number; size?: number; code?: string } = {},
  ) {
    // TODO: 暂时先根据短链码进行查询
    const { page = 1, size = 10, code = '' } = query;
    const builder = this.ruleRepo.createQueryBuilder('rule');
    const skipTotal = (page - 1) * size;
    if (code !== '') {
      builder.where('rule.code=:code', {
        code,
      });
    }
    const totalCount = await builder.getCount();
    const dataList = await builder
      .skip(skipTotal)
      .take(size)
      .orderBy('rule.id', 'DESC')
      .getMany();
    return {
      list: dataList,
      page: {
        page,
        total: totalCount,
      },
    };
  }

  async getRule(id: number) {
    const builder = this.ruleRepo.createQueryBuilder('rule');
    builder.where('rule.id = :id', { id });
    return builder.orderBy('rule.id', 'DESC').getOneOrFail();
  }

  /**
   * 创建短链
   * @param ruleDto
   * @returns
   */
  public async createRule(ruleDto: RuleDto) {
    // 使用 TypeORM 的 getManager 来获取 EntityManager
    const entityManager = this.ruleRepo.manager;
    // 使用 transaction 方法自动处理事务
    return await entityManager.transaction(
      async (transactionalEntityManager) => {
        const ruleEntity = new RuleEntity();
        // 临时码,先保证能插入的进去
        ruleEntity.code = 'TEMP';
        ruleEntity.createdAt = new Date();
        ruleEntity.createdBy = ruleDto.createBy;
        ruleEntity.updatedAt = new Date();
        ruleEntity.updatedBy = ruleDto.createBy;
        ruleEntity.description = ruleDto.description;
        ruleEntity.isValid = false; // 默认不生效
        ruleEntity.targetUrl = ruleDto.targetUrl ?? '';
        ruleEntity.strategyIds = ruleDto.strategyIds;
        ruleEntity.customStrategyFunc = ruleDto.customStrategyFunc ?? '';
        // 保存实体
        const saveEntity = await transactionalEntityManager.save(ruleEntity);
        const lastRuleId = saveEntity.id;
        // 膨胀规则ID
        const ruleId = exposeInt(lastRuleId + 1);
        if (!Number.isSafeInteger(ruleId)) {
          throw new Error('短链转换规则已用尽');
        }
        // 生成短链码
        const tinyUrlCode = getShortLinkCode(ruleId);
        saveEntity.code = tinyUrlCode;
        // 再次保存更新过后的实体
        return await transactionalEntityManager.save(saveEntity);
      },
    );
  }

  public async updateRule(
    ruleDto: Partial<Omit<RuleDto, 'code'>> & { id: number; isValid?: boolean },
  ) {
    const oldRuleEntity = await this.getRule(ruleDto.id);
    if (!oldRuleEntity) {
      throw new Error('没有找到预期的规则');
    }
    oldRuleEntity.updatedAt = new Date();
    oldRuleEntity.updatedBy = ruleDto.updateBy;
    if (ruleDto.customStrategyFunc) {
      oldRuleEntity.customStrategyFunc = ruleDto.customStrategyFunc;
    }
    if (ruleDto.targetUrl) {
      oldRuleEntity.targetUrl = ruleDto.targetUrl;
    }
    if (ruleDto.strategyIds) {
      oldRuleEntity.strategyIds = ruleDto.strategyIds;
    }
    if (ruleDto.description) {
      oldRuleEntity.description = ruleDto.description;
    }
    if (typeof ruleDto.isValid !== 'undefined') {
      oldRuleEntity.isValid = ruleDto.isValid;
    }
    return this.ruleRepo.save(oldRuleEntity);
  }

  async deleteRule(id: number) {
    const { affected } = await this.ruleRepo
      .createQueryBuilder()
      .delete()
      .from(RuleEntity)
      .where('id = :id', { id })
      .execute();
    return {
      total: affected ?? 0,
    };
  }
}

在上述代码中,我们就已经用到了前文提到的数据库的事务了,因为我们需要根据数据库的自增ID得到短链码,而我们要得到自增ID,我们首先得将这条数据插入到数据库中,而用户可能同时访问,假设不使用数据库的事务的话,如果我们先获取数据库的ID,再回写短链码,就可能出现相同的短链码,从而导致bug,使用事务就可以避免这个bug。

最后,把这些内容用一个Module关联起来。

import { Module } from '@nestjs/common';
import { RuleController } from './controllers/rule.controller';
import { StrategyController } from './controllers/strategy.controller';
import { RuleService } from './services/rule.service';
import { StrategyService } from './services/strategy.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Rule } from '@/modules/persistence/entities/rule';
import { Strategy } from '@/modules/persistence/entities/strategy';
import { RuleRepository } from './repositories/rule.repository';
import { StrategyRepository } from './repositories/strategy.repository';

@Module({
  // 引入TypeORM对Entity的管理
  imports: [TypeOrmModule.forFeature([Rule, Strategy])],
  controllers: [RuleController, StrategyController],
  providers: [RuleService, StrategyService, RuleRepository, StrategyRepository],
})
export class ManageModule {}

前端部分

前端部分,因为都是大家熟悉的东西,也没有什么技术含量,并且本专题的核心是NestJS的应用,就不贴前端代码了,给大家看看我做出来的页面的样子,各位同学照着还原吧,哈哈哈。

在前端需要使用到一个代码编辑器,之前在技术选型的时候,就已经提到过了,我们会用MonacoEditor,大家可以自己用社区提供的vue对于MonacoEditor的封装,一步到位。

如果对于这部分代码有需要的同学,可以在评论区联系我,我可以将脱敏之后的代码分享给大家。

egoist/vue-monaco: MonacoEditor component for Vue.js (github.com)

对策略的CRUD

管理页面: image.png 新增或编辑弹窗: image.png

对规则的CRUD

管理页面 image.png 新增或编辑弹窗: image.png 在新建或编辑规则的时候,可以选择系统中预设的跳转策略,从而实现动态跳转。 image.png

结语

对于短链的增删改查是一个没有什么技术含量的体力活儿,当我们把这个完成之后,基本上就完成整个系统的80%了。对于这个部分,大家有疑问的可以在评论区中留言,在下一节中,我们将对最后的网关跳转的逻辑进行编写,也是整个系统最核心的部分,敬请期待。