ThinkJS
首发于ThinkJS
ThinkJS关联模型实践

ThinkJS关联模型实践

在数据库设计特别是关系型数据库设计中,我们的各个表之间都会存在各种关联关系。在传统行业中,使用人数有限且可控的情况下,我们可以使用外键来进行关联,降低开发成本,借助数据库产品自身的触发器可以实现表与关联表之间的数据一致性和更新。

但是在 web 开发中,却不太适合使用外键。因为在并发量比较大的情况下,数据库很容易成为性能瓶颈,受IO能力限制,且不能轻易地水平扩展,并且程序中会有诸多限制。所以在 web 开发中,对于各个数据表之间的关联关系一般都在应用中实现。

在 ThinkJS 中,关联模型就可以很好的解决这个问题。下面我们来学习一下在 ThinkJS 中关联模型的应用。

场景模拟

我们以最常见的学生、班级、社团之间的关系来模拟一下场景。

创建班级表

CREATE TABLE `thinkjs_class` (
  `id` int(10) NOT NULL,
  `name` varchar(50) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

创建学生表

CREATE TABLE `thinkjs_student` (
  `id` int(10) NOT NULL,
  `class_id` int(10) NOT NULL,
  `name` varchar(20) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

创建社团表

CREATE TABLE `thinkjs_club` (
  `id` int(10) NOT NULL,
  `name` varchar(50) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

然后我们按照官网文档关联模型一一讲起,如果不熟悉官网文档建议先看一遍文档。

一对一

这个很好理解,很多时候一个表内容太多我们都会将其拆分为两个表,一个主表用来存放使用频率较高的数据,一个附表用来存放使用频率较低的数据。

我们可以对学生表创建一个附表,用来存放学生个人信息以便我们进行测试。

CREATE TABLE `thinkjs_student_info` (
  `id` int(10) NOT NULL,
  `student_id` int(10) NOT NULL,
  `sex` varchar(10) NOT NULL,
  `age` int(2) UNSIGNED NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

相对于主表来说,外键即是 student_id,这样按照规范的命名我们直接在 student 模型文件中定义一下关联关系即可。

// src/model/student.js
module.exports = class extends think.Model {
    get relation() {
        return {
          student_info: think.Model.HAS_ONE
        };
    }
}

然后我们执行一次查询

// src/controller/student.js
module.exports = class extends think.Controller {
    async indexAction() {
        const student=await this.model('student').where({id:1}).find();
        return this.success(student);
    }
}

即可得到主表与关联附表的数据

{
    "student": {
        "id": 1, 
        "class_id": 1, 
        "name": "王小明", 
        "student_info": {
            "id": 1, 
            "student_id": 1, 
            "sex": "男", 
            "age": 13
        }
    }
}

查看控制台,我们会发现执行了两次查询

[2018-08-27T23:06:33.760] [41493] [INFO] - SQL: SELECT * FROM `thinkjs_student` WHERE ( `id` = 1 ) LIMIT 1, Time: 12ms
[2018-08-27T23:06:33.764] [41493] [INFO] - SQL: SELECT * FROM `thinkjs_student_info` WHERE ( `student_id` = 1 ), Time: 2ms

第二次查询就是 ThinkJS 中的模型功能自动帮我们完成的。

如果我们希望修改一下查询结果关联数据的 key,或者我们的表名、外键名没有按照规范创建。那么我们稍微修改一下关联关系,即可自定义这些数据。

// src/model/student.js
module.exports = class extends think.Model {
    get relation() {
        return {
            info:{
                type:think.Model.HAS_ONE,
                model:'student_info',
                fKey:'student_id'
            }
        }
    }
}

再次执行查询,会发现返回数据中关联表的数据的 key,已经变成了 info

当然除了配置外键、模型名这里还可以配置查询条件、排序规则,甚至分页等。具体可以参考model.relation 支持的参数。

一对一(属于)

说完第一种一对一关系,我们来说第二种一对一关系。上面的一对一关系是我们期望查询主表后得到关联表的数据。也就是主表的主键thinkjs_student.id,是附表的外键thinkjs_student_info.student_id。那么我们如何通过外键查找到另外一张表的数据呢?这就是另外一种一对一关系了。

比如学生与班级的关系,从上面我们创建的表可以看到,学生表中我们通过thinkjs_student.class_id来关联thinkjs_class.id,我们在student模型中设置一下关联关系

// src/model/student.js
module.exports = class extends think.Model {
    get relation() {
        return {
            class: think.Model.BELONG_TO
        }
    }
}

查询后即可得到相关关联数据

{
    "student": {
        "id": 1, 
        "class_id": 1, 
        "name": "王小明", 
        "class": {
            "id": 1, 
            "name": "三年二班"
        }
    }
}

同样,我们也可以自定义数据的 key,以及关联表的表名、查询条件等等。

一对多

一对多的关系也很好理解,一个班级下面有多个学生,如果我们查询班级的时候,想把关联的学生信息也查出来,这时候班级与学生的关系就是一对多关系。这时候设置模型关系就要在 class 模型中设置了

// src/model/class.js
module.exports = class extends think.Model {
    get relation() {
        return {
            student:think.Model.HAS_MANY
        }
    }
}

即可得到关联学生数据

{
    "id": 1, 
    "name": "三年二班", 
    "student": [
        {
            "id": 1, 
            "class_id": 1, 
            "name": "王小明"
        }, 
        {
            "id": 2, 
            "class_id": 1, 
            "name": "陈二狗"
        }
    ]
}

当然我们也可以通过配置参数来达到自定义查询

// src/model/class.js
module.exports = class extends think.Model {
    get relation() {
        return {
            list:{
                type:think.Model.HAS_MANY,
                model:'student',
                fKey: 'class_id',
                where:'id>0',
                field:'id,name',
                limit:10
            }
        }
    }
}

设置完之后我们测试一下,会发现页面一直正在加载,打开控制台会发现一直在循环执行几条sql语句,这是为什么呢?

因为上面的一对一例子,我们是用 student 和 class 做了 BELONG_TO 的关联,而这里我们又拿 class 和 student 做了 HAS_MANY 的关联,这样就陷入了死循环。我们通过官网文档可以看到,有个 relation 可以解决这个问题。所以我们把上面的 student 模型中的 BELONG_TO 关联修改一下

// src/model/student.js
module.exports = class extends think.Model {
    get relation() {
        return {
            class: {
                type:think.Model.BELONG_TO,
                relation:false
            }
        }
    }
}

这样,即可在正常处理 class 模型的一对多关系了。如果我们想要在 student 模型中继续使用 BELONG_TO 来得到关联表数据,只需要在代码中重新启用一下即可

// src/controller/student.js
module.exports = class extends think.Controller {
    async relationAction(){
        let student=await this.model('student').setRelation('class').where({id:2}).find();
        return this.success(student);
    }
}

官网文档 model.setRelation(name, value) 有更多关于临时开启或关闭关联关系的使用方法。

多对多

前面的一对一、一对多还算很容易理解,多对多就有点绕了。想象一下,每个学生可以加入很多社团,而社团同样由很多学生组成。社团与学生的关系,就是一个多对多的关系。这种情况下,两张表已经无法完成这个关联关系了,需要增加一个中间表来处理关联关系

CREATE TABLE `thinkjs_student_club` (
  `id` int(10) NOT NULL,
  `student_id` int(10) NOT NULL,
  `club_id` int(10) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

根据文档中多对多关系的介绍,当我们在 student 模型中关联 club 时,rModel 为中间表,rfKey 就是 club_id

// src/model/student.js
module.exports = class extends think.Model {
    get relation() {
        return {
            club:{
                type: think.Model.MANY_TO_MANY,
                rModel: 'student_club',
                rfKey: 'club_id'
            }
        }
    }
}

如果我们想在 club 模型中关联 student 的数据,只需要把 rfKey 改为 student_id 即可。

当然,多对多也会遇到循环关联问题。我们只需要把其中一个模型设置 relation:false 即可。

关联循环

在上面我们多次提到关联循环问题,我们来试着从代码执行流程来理解这个 feature。

think-model第30行 看到,在构造方法中,会有一个 Relation 实例放到 this[RELATION]

RELATION 是由 Symbol 函数生成的一个Symbol类型的独一无二的值,在这里应该是用来实现私有属性的作用。

然后略过 new Relation() 做了什么,来看一下模型中 select 这个最终查询的方法来看一下,在第576行发现在执行了const data = await this.db().select(options);查询之后,又调用了一个 this.afterFind 方法。而this.afterFind方法又调用了上面提到的 Relation 实例的 afterFind 方法 return this[RELATION].afterFind(data);

看到这里我们通过命名几乎已经知道了大概流程:就是在模型正常的查询之后,又来处理关联模型的查询。我们继续追踪代码,来看一下 RelationafterFind 方法又调用了 this.getRelationDatathis.getRelationData则开始解析我们在模型中设置的 relation 属性,通过循环来调用 parseItemRelation 得到一个 Promise 对象,最终通过 await Promise.all(promises);来全部执行。

parseItemRelation方法则通过调用 this.getRelationInstance 来获得一个实例,并且执行实例的 getRelationData 方法,并返回。所以上面 this.getRelationData 方法中 Promise.all 执行的其实都是 this.getRelationInstance 生成实例的 getRelationData 方法。

getRelationInstance的作用就是,解析我们设置的模型关联关系,来生成对应的实例。然后我们可以看一下对应的 getRelationData 方法,最终又执行了模型的select方法,形成递归闭环。

从描述看起来似乎很复杂,其实实现的很简单且精巧。在模型的查询方法之后,分析模型关联以后再次调用查询方法。这样无论有多少个模型互相关联都可以查询出来。唯一要注意的就是上面提到的互相关联问题,如果我们的模型存在互相关联问题,可以通过 relation:false 来关闭。

后记

通过上面的实践可以发现,ThinkJS 的关联模型实现的精巧且强大,通过简单的配置,即可实现复杂的关联。而且通过 setRelation 方法动态的开启和关闭模型关联查询,保证了灵活性。只要我们在数据库设计时理解关联关系,并且设计合理,即可节省我们大量的数据库查询工作。

PS:以上代码放在github.com/lscho/thinkj

发布于 2018-08-30

文章被以下专栏收录

    ThinkJS 是一款基于 Koa 2.0 面向未来的企业级 Node.js 框架,致力于整合了大量的项目最佳实践,让企业级开发变得如此简单、高效。本专栏会分享一些在 ThinkJS 项目开发过程中总结的一些经验以及问题,同时也非常欢迎大家投稿。