我们经常会见到一些多级分类的场景:
比如京东的商品分类:
新闻网站的新闻分类:
这种多层级的数据怎么存储呢?
有同学会说,很简单啊,这不就是一对多么,二级分类就用两个表,三级分类就用三个表。
这样是可以,但是都是分类,表结构是一样的,分到多个表里是不是有点冗余。
更重要的是,如果层级关系经常调整呢?
比如有的时候会变成二级分类,有的时候会更多级分类呢?
这时候用普通的多表之间的一对多就不行了。
一般这种多级分类的业务,我们都会在一个表里存储,然后通过 parentId 进行子关联来实现。
在 TypeORM 里也对这种场景做了支持。
我们新建个项目:
nest new typeorm-tree-entity-test
进入项目目录,创建一个 CRUD 模块:
nest g resource city --no-spec
然后安装 TypeORM 的包:
npm install --save @nestjs/typeorm typeorm mysql2
在 app.module.ts 引入下 TypeOrmModule:
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { CityModule } from './city/city.module';
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
imports: [
CityModule,
TypeOrmModule.forRoot({
type: "mysql",
host: "localhost",
port: 3306,
username: "root",
password: "guang",
database: "tree_test",
synchronize: true,
logging: true,
entities: [City],
poolSize: 10,
connectorPackage: 'mysql2',
extra: {
authPlugin: 'sha256_password',
}
})
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
在 mysql workbench 里创建这个 database:
指定字符集为 utf8mb4,点击 apply。
然后改下 city.entity.ts
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, Tree, TreeChildren, TreeParent, UpdateDateColumn } from "typeorm";
@Entity()
@Tree('closure-table')
export class City {
@PrimaryGeneratedColumn()
id: number;
@Column({ default: 0 })
status: number;
@CreateDateColumn()
createDate: Date;
@UpdateDateColumn()
updateDate: Date;
@Column()
name: string;
@TreeChildren()
children: City[];
@TreeParent()
parent: City;
}
把服务跑起来:
npm run start:dev
可以看到,自动创建了 2 个表:
我们在 mysql workbench 里看下:
可以看到 parentId 引用了自身的 id。
并且还有个 city_closure 表:
两个外键都引用了 city 表的 id。
先不着急解释为什么是这样的,我们插入一些数据试试:
在 CityService 的 findAll 方法里插入数据,然后再查出来。
@InjectEntityManager()
entityManager: EntityManager;
async findAll() {
const city = new City();
city.name = '华北';
await this.entityManager.save(city);
const cityChild = new City()
cityChild.name = '山东'
const parent = await this.entityManager.findOne(City, {
where: {
name: '华北'
}
});
if(parent){
cityChild.parent = parent
}
await this.entityManager.save(City, cityChild)
return this.entityManager.getTreeRepository(City).findTrees();
}
这里创建了两个 city 的 entity,第二个的 parent 指定为第一个。
用 save 保存。
然后再 getTreeRepository 调用 findTrees 把数据查出来。
浏览器访问下:
可以看到数据插入成功了,并且返回了树形结构的结果。
在 mysql workbench 里看下:
在 city 表里保存着 city 记录之间的父子关系,通过 parentId 关联。
在 city_closure 表里记录了也记录了父子关系。
把插入数据的代码注释掉:
重新插入数据:
async findAll() {
const city = new City();
city.name = '华南';
await this.entityManager.save(city);
const cityChild1 = new City()
cityChild1.name = '云南'
const parent = await this.entityManager.findOne(City, {
where: {
name: '华南'
}
});
if(parent){
cityChild1.parent = parent
}
await this.entityManager.save(City, cityChild1)
const cityChild2 = new City()
cityChild2.name = '昆明'
const parent2 = await this.entityManager.findOne(City, {
where: {
name: '云南'
}
});
if(parent){
cityChild2.parent = parent2
}
await this.entityManager.save(City, cityChild2)
return this.entityManager.getTreeRepository(City).findTrees();
}
跑一下:
可以看到,二层和三层的关系都可以正常的存储和查询。
把插入数据的代码注释掉,我们测试下其他方法:
findRoots 查询的是所有根节点:
async findAll() {
return this.entityManager.getTreeRepository(City).findRoots()
}
async findAll() {
const parent = await this.entityManager.findOne(City, {
where: {
name: '云南'
}
});
return this.entityManager.getTreeRepository(City).findDescendantsTree(parent)
}
findDescendantsTree 是查询某个节点的所有后代节点。
async findAll() {
const parent = await this.entityManager.findOne(City, {
where: {
name: '云南'
}
});
return this.entityManager.getTreeRepository(City).findAncestorsTree(parent)
}
findAncestorsTree 是查询某个节点的所有祖先节点。
这里换成 findAncestors、findDescendants 就是用扁平结构返回:
把 findTrees 换成 find 也是会返回扁平的结构:
还可以调用 countAncestors 和 countDescendants 来计数:
async findAll() {
const parent = await this.entityManager.findOne(City, {
where: {
name: '云南'
}
});
return this.entityManager.getTreeRepository(City).countAncestors(parent)
}
这些 api 都是很实用的。
回过头来,再看下 @Tree 的 entity:
通过 @TreeChildren 声明的属性里存储着它的 children 节点,通过 @TreeParent 声明的属性里存储着它的 parent 节点。
并且这个 entity 要用 @Tree 声明。
参数可以指定 4 中存储模式:
我们一般都是用 closure-table,或者 materialized-path。
其余两种有点问题:
把两个表删掉:
改成 materialized-path 重新跑:
可以看到,现在只生成了一个表:
只是这个表多了一个 mpath 字段。
我们添加点数据:
async findAll() {
const city = new City();
city.name = '华北';
await this.entityManager.save(city);
const cityChild = new City()
cityChild.name = '山东'
const parent = await this.entityManager.findOne(City, {
where: {
name: '华北'
}
});
if(parent){
cityChild.parent = parent
}
await this.entityManager.save(City, cityChild)
return this.entityManager.getTreeRepository(City).findTrees();
}
可以看到,它通过 mpath 路径存储了当前节点的访问路径,从而实现了父子关系的记录:
其实这些存储细节我们不用关心,不管是 closure-table 用两个表存储也好,或者 materialized-path 用一个表多加一个 mpath 字段存储也好,都能完成同样的功能。
案例代码在小册仓库。
总结
这节我们基于 TyepORM 实现了任意层级的关系的存储。
在 entity 上使用 @Tree 标识,然后通过 @TreeParent 和 @TreeChildren 标识存储父子节点的属性。
之后可以用 getTreeRepository 的 find、findTrees、findRoots、findAncestorsTree、findAncestors、findDescendantsTree、findDescendants、countDescendants、countAncestors 等 api 来实现各种关系的查询。
存储方式可以指定 closure-table 或者 materialized-path,这两种方式一个用单表存储,一个用两个表,但实现的效果是一样的。
以后遇到任意层级的数据的存储,就是用 Tree Entity 吧。