从0到1实现NestJS的服务端应用——数据库篇
起步
从这里开始,我们将对nest.js的对数据操作的基本应用做一个简单的讲解,主要是明白如何通过访问接口来进行增删改查的基本操作。
介绍
这里我们使用的数据库是Mysql数据库,可以进入Mysql官方下载地址按照自己的系统选择下载,目前我这里使用的版本图如下:

工欲善其事必先利其器,接着就需要安装一个数据库的可视化应用,我这里下载的是Navicat软件。
一切准备就绪后,就可以进行下一个步骤了。
TypeORM
TypeORM 是一个ORM框架,它可以运行在 NodeJS、Browser、Cordova、PhoneGap、Ionic、React Native、Expo 和 Electron 平台上,可以与 TypeScript 和 JavaScript (ES5,ES6,ES7,ES8)一起使用。 它的目标是始终支持最新的 JavaScript 特性并提供额外的特性以帮助你开发任何使用数据库的(不管是只有几张表的小型应用还是拥有多数据库的大型企业应用)应用程序。 -- 摘自官网
在nest.js中,内置并推荐我们使用TypeORM来操作数据库,至于什么是ORM?简单来说就是把JavaScript的对象映射为数据的一张表,通过操作该对象来使得影响数据库中表的结果。
连接数据库
安装完数据库后,首先我使用Navicat来连接Mysql数据库,看看是否能成功连接,执行步骤如下:
- 进入Navicat软件,点击连接,会弹出一个提示框让我们输入凭证;
 - 输入对应的地址和用户名、密码,点击测试连接,看到下图即表达连接可行:
 

- 点击OK进行连接,就完成了。
 
在Navicat中测试连接成功后,我们就需要在nest.js来进行数据库的间接了,连接的代码如下所示:
// app.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'mysql',
      host: 'localhost',
      port: 3306,
      username: 'root',
      password: 'password',
      database: 'demo',
      autoLoadEntities: true,
      synchronize: true,
    })
  ]
})
export class AppModule {}
- type: 表示要连接的数据库类型,因为TypeORM支持多种数据库;
 - host:连接的目标主机号,我们是本地的服务,所以是localhost;
 - port:数据库的端口号,mysql默认为3306;
 - username:登入数据库的用户名;
 - password:登入数据库的密码;
 - database:要使用的mysql数据库;
 - autoLoadEntities:自动加载实体类去映射到数据库;
 - synchronize:是否同步本地的修改到数据库。
 
警告:synchronize严禁在生产环境下使用,因为有可能会导致数据的丢失。
完成以上配置后,执行npm run start:dev启动服务,没有报错的话,就表示数据库已经连接成功了。
操作数据库
还记得之前建立的商品实体对象嘛?当时,这个对象就仅仅当做类型来使用了一下,并没有其他用处。但是,在我们是使用TypeORM后,我们就可以让实体类和数据库进行一个映射关系。
关联实体
现在,先来改造一下实体类,代码如下:
import {
  Column,
  Entity,
  JoinTable,
  ManyToMany,
  PrimaryGeneratedColumn,
} from 'typeorm';
import { Keywords } from './keywords.entity';
@Entity()
export class Goods {
  @PrimaryGeneratedColumn()
  id: number;
  
  @Column()
  name: string;
  @Column({
    type: Number,
  })
  price: number;
  
  @Column()
  brand: string;
}
在上面的实体类代码中,我们通过@Entity()装饰器修饰了该实体,并且通过@Column()装饰器装饰每个字段,其中比较特殊的是@PrimaryGeneratedColumn(),这个装饰器表示修饰的该字段是一个自增主键。
当实体类改造完毕以后,我们就需要在goods.service中注入存储库对象,这个对象就是用来操作数据库的。在注入存储库之前,我们得先做一个操作,就是需要在goods.module中进行注册,代码如下:
import { TypeOrmModule } from '@nestjs/typeorm';
import { Module } from '@nestjs/common';
import { GoodsController } from './goods.controller';
import { GoodsService } from './goods.service';
import { Goods } from './entities/goods.entity';
@Module({
  imports: [TypeOrmModule.forFeature([Goods])],
  controllers: [GoodsController],
  providers: [GoodsService],
})
export class GoodsModule {}
如果操作一切正确,打开Navicat我们就能发现多了一张goods表了!
注入存储库
通过TypeOrmModule.forFeature([Goods])该方法后,就实现了存储库的注册,现在我们在goods.service进行注入,代码如下:
import { UpdateGoodsDto } from './dto/update-goods.dto';
import { CreateGoodsDto } from './dto/create-goods.dto';
import { Goods } from './entities/goods.entity';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
@Injectable()
export class GoodsService {
  constructor(
    @InjectRepository(Goods)
    private readonly goodsRepositroy: Repository<Goods>,
  ) {}
  async findGoodsById(id: number) {
    return await this.goodsRepositroy.findOneBy({ id });
  }
}
可以看到,在构造函数中我们定义了一个通过@InjectRepository(Goods)装饰器修饰的goodsRepository对象,通过该对象我们就可以操作数据库了。
在下面的findGoodsById方法中,就通过了findOneBy({ id })方法对数据库进行了一个条件的查询,现在我通过Navigate工具手动添加一条数据:

数据添加完成后,尝试进行对商品查询的接口调用,结果如下图:

一切都很好,说明我们的查询功能十分顺利。
存储库管理
可以看到,上面我们通过定义的goodsRepositroy对象就可以查询数据库了,那么这个goodsRepositroy是什么呢?
其实goodsRepositroy就是存储库管理Repository实例的对象,通过该对象我们可以访问指定实体对应的数据库关系,并且该对象提供了一系列方法供我们来使用,比如下面常用的4个Api方法:
- find:查询出满足条件的所有数据;
 - findOneBy:通过指定条件查询一条数据出来
 - save:即可用作保存,也可用作修改操作;
 - delete:根据条件删除数据;
 
表之间关联
之前我们定义Goods实体的时候使用keywords属性来存放关键字,但实际上关键字作为一个实体更合理。现在我们以Keywords来新建另一个实体,并且让Goods和Keywords来进行一个关联。
新建Keywords实体类文件,代码如下:
import { Goods } from './goods.entity';
import { Column, Entity, ManyToMany, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
export class Keywords {
  @PrimaryGeneratedColumn()
  id: number;
  
  @Column()
  name: string;
}
当程序编译完成,数据库中多了一张keywords表,接下来我们就需要把Keywords和Goods实体关联起来。
不难知道,商品和关键字的关系是多对多的关系,因为一个关键字可以对应多个商品,一个商品也可以拥有多个关键字,所以这里我们需要TypeORM提供的@ManyToMany()装饰器来修饰,代码如下:
// goods.entity.ts
@JoinTable()
@ManyToMany((type) => Keywords, (keywords) => keywords.goods, {
  cascade: true,
})
keywords: Keywords[];
// keywords.entity.ts
@ManyToMany((type) => Goods, (goods) => goods.keywords, {
  cascade: true,
})
goods: Goods[];
现在来说明一下上面代码的意思:
- @JoinTable():表示关系的拥有者,只需要在其中一个定义即可;
 - @ManyToMany():表示两个实体间的关系为多对多
- arg1:表示需要建立关系的实体类对象;
 - arg2:建立关系的实体类对象引用的当前实体类属性;
 - options:cascade表示是否级联,级联之后添加和修改时,主表都会影响从表的记录。
 
 
注意:多对多关系会生成三张表,多出来的那一张是中间表,记录两个表数据的对应关系。
完成以上步骤后,我们现在尝试进行数据的保存,代码如下:
// goods.service.ts
async addGoods(createGoodsDto: CreateGoodsDto) {
    const keywords = await Promise.all(
      createGoodsDto.keywords.map((item) => this.preloadKeywordsByName(item)),
    );
    const goods = this.goodsRepositroy.create({
      ...createGoodsDto,
      keywords,
    });
    return await this.goodsRepositroy.save(goods);
}
private async preloadKeywordsByName(name: string) {
    const keywords = await this.keywordsRepository.findOneBy({ name });
    if (keywords) {
      return keywords;
    }
    return this.keywordsRepository.create({ name });
}
上面我们通过顶一个preloadKeywordsByName方法来根据关键字名称来加载实体:
- 表中存在关键字,存在直接返回该实体;
 - 表中没有关键字,则创建一个实体对象;
然后通过传递进来的
keywords参数遍历生成对应的实体对象,进行save()操作后nest.js就会将我们的数据分别保存到三个表中。 
通过接口调用,结果如下图:



可以看到,我们虽然只操作了Goods实体的保存,但是也自动的在keywords表中进行了数据的添加,这是因为cascade级联起的作用。
好的,我们再进行查询,结果如图:

发现,没有keywords关键字,这是因为当我们查询时,如果需要查询出关联的表的话,需要加上relations参数来指定关联,如下所示:
async findGoodsById(id: number) {
  return await this.goodsRepositroy.findOne({
    relations: ['keywords'],
    where: { id },
  });
}
通过添加relations关联后,我们再进行查询,可以发现keywords对应的信息也出现了,如下图:

分页查询
查询数据的时候,一般都会进行分页的一个筛选查询,否则当数据量大的时候,全部查询出来的效率是比较低的,也是不好处理的。
进行分页查询需要用到两个参数,一个是skip表示跳过的记录数,另一个是take表示需要查询的记录数量,代码如下:
async findAll(paginationQueryDto: PaginationQueryDto) {
    const { page, size } = paginationQueryDto;
    return this.goodsRepositroy.find({
      skip: (page - 1) * size,
      take: size,
    });
}
我们通过上面的方式,来实现根据页数来查询对应数量的记录,尝试调接口看一看返回,如图:


结果发现,数据是能够分页信息查询出来的!
事务
当我们在做某个功能的时候,可能需要操作多次数据库,有可能在其中的某一次操作中会发生异常导致失败。但此时,成功的操作也无法逆转,导致数据出现问题。 此时,我们就需要通过事务来解决这个问题。简单说,事务就是保证多次操作数据库时的一致性,要么都成功,要么都失败。
使用事务时,我们需要通过注入的方式来引用,代码如下:
@Injectable()
export class GoodsService {
  constructor(
    @InjectRepository(Goods)
    private readonly goodsRepositroy: Repository<Goods>,
    @InjectRepository(Keywords)
    private readonly keywordsRepository: Repository<Keywords>,
    private readonly dataSource: DataSource,
  ) {}
}
其中,private readonly dataSource: DataSource定义了一个数据源对象,这个对象不用通过装饰器来装饰也能被nest.js给我们进行管理。
接着,我们再来看看执行事务的代码,如下:
async recommendGoods(id: number) {
    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();
    try {
      const goods = await this.goodsRepositroy.findOneBy({ id });
      goods.recommend++;
      const recommentEvent = new Event();
      recommentEvent.name = 'recommend_goods';
      recommentEvent.type = 'goods';
      recommentEvent.payload = { goodsId: goods.id };
      await queryRunner.manager.save(goods);
      await queryRunner.manager.save(recommentEvent);
      await queryRunner.commitTransaction();
    } catch (err) {
      await queryRunner.rollbackTransaction();
    } finally {
      await queryRunner.release();
    }
  }
上面,我们通过dataSource对象创建了一个QueryRunner对象,通过该对象我们就可以建立连接,并手动控制事务的状态。
- startTransaction(): 开启事务;
 - commitTransaction():提交所有的更改;
 - queryRunner.rollbackTransaction():事务回滚,撤销所有更改;
 - queryRunner.release():释放当前的连接;
 
数据迁移
数据迁移的作用在于,当我们可能需要修改表结构时:如果直接通过修改实体类的话,会导致表里面的数据造成丢失;所以需要通过数据迁移文件来操作数据库,进而达到修改表结构的目的。
现在,我们通过TypeORM CLI来创建一个数据迁移的文件,输入以下命令:
npx typeorm migration:create src/migrations/KeywordsRefactor
执行该命令后,在我们的src/migrations目录下就会生成一个{timestamp}-KeywordsRefactor.ts的文件(timestamp表示自动生成的时间戳),进入该文件发下以下内容:
import { MigrationInterface, QueryRunner } from 'typeorm';
export class KeywordsRefactor1678969889283 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {}
  public async down(queryRunner: QueryRunner): Promise<void> {}
}
其中,up方法表示迁移所需要执行的sql代码;而down表示迁移失败之后,需要回复up操作的方法。
例如,我们这里将keywords表的name字段改为title字段,代码如下:
import { MigrationInterface, QueryRunner } from 'typeorm';
export class KeywordsRefactor1678969889283 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    queryRunner.query(`
      ALTER TABLE keywords CHANGE name title varchar(255);
    `);
  }
  public async down(queryRunner: QueryRunner): Promise<void> {
    queryRunner.query(`
      ALTER TABLE keywords CHANGE title name varchar(255);
    `);
  }
}
在执行迁移之前,我们还需要创建一个db.config.ts配置文件,内容如下:
import { DataSource } from 'typeorm';
const AppDataSource = new DataSource({
  type: 'mysql',
  host: 'localhost',
  port: 3306,
  username: 'root',
  password: '',
  database: 'demo',
  entities: ['dist/**/*.entity.js'],
  migrations: ['dist/**/migrations/*.js'],
});
export default AppDataSource;
配置就和建立数据库连接时的配置基本一样,只是多了两个字段:
- entities:表示实体类的存储位置;
 - migrations:表示迁移文件的存储位置;
 
因为这里我们迁移过程是根据打包后的dist目录来识别的,所以在迁移之前先进行build构建,执行命令:
npm run build
当生成好dist目录后,我们就可以进行迁移命令了:
npx typeorm-ts-node-esm migration:run -d db.config.ts
看到终端提示如下信息的话,就表明迁移已经成功了:

并且数据库里面keywords表的字段也已经被修改了:

此时,如果我们执行下面命令:
npx typeorm-ts-node-esm migration:revert -d db.config.ts
再次查看keywords表结构,发现字段名称又被改回去了:

注意:执行了run命令迁移之后的文件会被记录到数据库中,不会再次执行;当revert后记录会被删除。
完结
基于nest.js配合TypeORM的数据库操作就简单介绍这里,实际上还有很多的API提供使用,但内容太过繁多,不便细写。
转载自:https://juejin.cn/post/7211074223758114872