LoopBack3.0最佳实践(三)——面向Model编程


1. Model的继承关系

虽然我们在定义一个Model时,只需要配置一些属性,但LoopBack会将这些Model转换为一个Class。在LoopBack中有三种类型的Model Class,一个用户定义的Model被转换成哪种Class取决于它继承了哪一种父类:

  • 基类(Base Model Class):这是所有Model的父类,地位类似于Java里面的Object。这个类里面封装了REST API的全部相关功能。所以这意味着任何一个LoopBack的Model天生就可以是RESTful的。Model配置文件中的base属性设定为Model时,会继承该类,开发者需要手动编写所有的API方法。
  • 数据持久类(PersistedModel Class):连接数据源进行数据持久化的类。在基类的基础上自带了数据的增删改查方法,这些方法直接可以暴露为REST API。Model配置文件中的base属性设定为PersistedModel时继承该类(或者不设定,默认情况下继承该类),这是最常用的Model类型。
  • 内置类(Built-in Model):包括User、Role和ACL等。用户可以直接在model-config.json中直接引用这些LoopBack提供的内置类,来实现用户认证和权限控制等相关功能。当然这些内置类也可以被继承。

下图是官方给出的Model继承关系图
Model inheritance

在上一篇文章中我们提到

在Loopback的世界里,一个Model不仅仅是Property的集合,还可以提供REST API Endpoint方法,并且集成ORM功能。开发者仅需要定义Property和配置参数,Loopback会自动集成API和数据持久化方法。

这种可以直接打通API层到数据持久层的逻辑的杀手锏,就是数据持久类PersistedModel。不用写一行业务逻辑代码,它就把Java程序员熟悉的Controller和DAO的基本功能全部完成了。

这确实会提高开发效率,但也容易引发开发者关于代码架构的困惑。传统的Web开发的分层架构也许不再那么适用于LoopBack,业务逻辑代码可能要更多地围绕着Model去实现,可以说需要“面向Model编程”。在讨论这个话题之前,我们不妨先将Model的API功能与ORM功能剥离开,看一下LoopBack是怎么支持复杂业务逻辑开发的。

2. ORM功能

支持多种数据源

PersistedModel通过Datasource可以连接多种数据源,除了各种数据库之外,甚至连Email服务都可以成为数据源

丰富的CRUD方法

LoopBack为PersistedModel集成了下面这些CRUD方法,既有类方法(Static Method)也有实例方法(Instance Method),常用功能全覆盖。

通过这些方法我们可以轻松实现对数据库的访问:

// 这些CURD方法有callback和promise两种调用方式:
// 1. callback方式
CoffeeShop.findById(shopId, function (err, instance) {
if (err)
console.error(err);
else
console.log(instance);
});
// 2. promise方式
CoffeeShop.findById(shopId).then(function (instance) {
console.log(instance);
}).catch(function (err) {
console.error(err);
});

支持建立Model间的关系

LoopBack支持以下几种关系:

  • BelongsTo
  • HasOne
  • HasMany
  • HasManyThrough
  • HasAndBelongsToMany
  • Polymorphic
  • Embedded (EmbedsOne/EmbedsMany/EmbedsMany with belongsTo)
  • ReferenceMany

定义一个Model的Relation可以使用交互命令lb relation,或者直接修改Model配置文件,以belongsTo为例:

{
"name": "Review",
"base": "PersistedModel",
... // 此处略
"relations": {
"coffeeShop": {
"type": "belongsTo", // 与CoffeeShop建立BelongsTo关系
"model": "CoffeeShop",
"foreignKey": "" // 这里没有指定外键,默认为coffeeShopId
}
}
}

Model间的关系通过外键关联,可实现关联查询

// 查找所有的Review记录,并返回其关联的coffeeShop的信息
Review.find({"include":["coffeeShop"]}).then(function(instances) {
console.log(instances);
});

更多关于Model关系的用法,敬请期待本系列的后续文章。

数据校验

LoopBack针对Model实例数据的校验提供了validation方法

validatesAbsenceOf: 检查Model实例是否不包含某些属性
validatesExclusionOf: 检查Model实例的某一个属性是否不等于某些值
validatesFormatOf: 检查Model实例的某一个属性是否符合一个正则表达式的格式
validatesInclusionOf: 检查Model实例的某一个属性是否等于某些值
validatesLengthOf: 校验Model实例的某属性的长度
validatesNumericalityOf: 校验Model实例的某属性是否为数值格式
validatesPresenceOf: 检查Model实例是否包含某些属性
validatesUniquenessOf: 校验Model实例某属性的唯一性
validatesDateOf: 校验Model实例的某属性是否为日期格式

Model定义文件中调用这些校验方法后方可生效:

module.exports = function(CoffeeShop) {
// validation方法
CoffeeShop.validatesLengthOf('name', {min: 2, message: {min: 'name is too short'}});
CoffeeShop.validatesInclusionOf('city', {in: ['Beijing', 'Shanghai']});
// 自定义的validation方法
CoffeeShop.validate('city', function(err) {
if (this.city && this.city.length > 15) {
return err();
}
}, {
message: 'city value is too long'
});
... // 此处略
}

默认情况下,这些校验方法会在Model实例创建或更新之前被自动调用,保证了合法数据才能被持久化。下面看在新增一个CoffeShop实例时,非法数据的例子:

var CoffeeShop = app.models.CoffeeShop;
var instanceData = {
'name': 'hi coffee',
'city': 'Shijiazhuang'
};
CoffeeShop.create(instanceData)
.then(result => console.log(result))
.catch(err => console.error(err));

请求数据中,city这个属性的值Shijiazhuang不符合validatesInclusionOf的规则,抛出异常:

Error: 
{ ValidationError: The `CoffeeShop` instance is not valid. Details: `city` is not included in the list (value: "Shijiazhuang").
... // 此处略

3. REST API

Remote Method

上文我们提到LoopBack会把PersistedModel的CRUD方法自动暴露为REST API,但如果我们要自定义一个API,则需要用到Remote Method。分为注册和定义两步:

module.exports = function(CoffeeShop) {
// 1. 注册一个remoteMethod
CoffeeShop.remoteMethod('status', {
description: 'get the status of a CoffeeShop',
accepts: [
{arg: 'id', type: 'string', required: true, description: 'CoffeeShop Id', http: {source: 'path'}}
], // 定义请求参数格式,支持在path/body/query中携带参数
returns: {arg: 'status', type: 'object', description: '', root: true}, // 定义返回结果的格式
http: {path: '/:id/status', verb: 'get', status: 200, errorStatus: 500} // 定义HTTP相关属性
});

// 2. 定义相应的remoteMethod
CoffeeShop.status = function(id, cb) { // 用callback的方式返回结果
CoffeeShop.findById(id).then(shop => {
if (!shop) {
var error = new Error('Coffee Shop ' + id + ' can not be found');
error.statusCode = 404;
return cb(error); // 返回错误信息
}
var status = 'Coffee Shop ' + id + ' is open now';
cb(null, status); // 返回结果
});
};
}

除了callback的方式外,Remote Method也支持以promise的方式返回结果

CoffeeShop.status = function(id) { // 直接return一个promise
return CoffeeShop.findById(id).then(shop => {
if (!shop) {
var error = new Error('Coffee Shop ' + id + ' can not be found');
error.statusCode = 404;
throw error; // 处理异常
}
var status = 'Coffee Shop ' + id + ' is open now';
return status;
});
};

正确请求API时的返回结果

curl -X GET http://localhost:3000/api/CoffeeShop/1/status


错误请求的结果

curl -X GET http://localhost:3000/api/CoffeeShop/4/status

API参数校验

上文中我们用validation方法实现了对Model实例数据的检验。但如果要利用这个功能实现对API请求参数的校验,则可以定义一个专用的Request Model:

{
"name": "APIRequestModel",
"base": "Model", // 基类设置为Model
"idInjection": false, // 取消id的自动注入
"strict": true, // 必需严格符合属性的定义
"properties": {
"id": false, // 取消id字段
"param1": {
"type": "string",
"required": true
},
"param2": {
"type": "string"
}
},
"validations": [],
"relations": {},
"acls": [],
"methods": {}
}

api-request-model.js里面加入一些validation方法:

module.exports = function(APIRequestModel) {
APIRequestModel.validatesLengthOf('param1', {max: 6, message: {max: 'length is too long'}});
APIRequestModel.validatesExclusionOf('param2', {in: ['string'], message: {in: 'can not be `string`'}});
}

那么如何利用APIRequestModel对参数进行校验?第一步,在注册Remote Method时将API的请求参数的类型设置为APIRequestModel,然后API在被请求时,LoopBack会自动把请求数据转换为APIRequestModel的实例。第二步,在Remote Method中调用该实例的isValid方法,触发数据校验:

APIModel.remoteMethod('testRequestValidation', {
description: 'test the validation of the request data',
accepts: [
{arg: 'data', type: 'APIRequestModel', required: true, description: 'Request Data', http: {source: 'body'}}
], // 请求参数的type一定要设置成相应的Model
returns: {arg: 'result', type: 'boolean', description: '', root: true},
http: {path: '/validation', verb: 'post', status: 200, errorStatus: 500}
});

APIModel.testRequestValidation = function(data) {
if (!data.isValid()) { // 调用isValid方法来校验输入数据
var err = new Error('Invalid Request Data');
err.statusCode = 400;
err.stack = data.errors; // 获取错误信息
throw err;
}
return Promise.resolve(true);
};

4. 面向Model编程

通过上面的介绍我们可以看到,LoopBack里的一切功能皆围绕着Model展开,Model承担着传统Web应用分层架构中Controller和DAO两种角色。在实际项目中使用LoopBack框架时,如果API的请求/返回数据的格式和数据库的Schema比较接近,可以允许Model同时实现API逻辑和ORM逻辑。但对于数据模型比较复杂的Web应用,如果对不加以区分,可能会导致代码的耦合。所以我们要考虑如何组织应用程序中的Model,使得代码架构更加合理。

一种思路是,将Model在逻辑上区分为“API Model”和“Data Model”,前者并不绑定数据源,只负责暴露API方法,后者连接数据源,负责CRUD。“API Model”在实现时,可以同时辅以“API Request Model”和”API Response Model“,规范和校验API的请求和返回数据。“Data Model”也可以在逻辑上进行进一步区分,将那些连接第三方服务的Model称为“Service Data Model”,以区别于用于持久化数据到数据库的“DB Data Model”:

在大部分应用场景下,一切皆可为Model,因为Model在本质上讲就是Class。当业务逻辑和代码架构都围绕着Model展开时,就是在“面向Model编程”。

当然这也是一家之言,欢迎留言讨论。另外,本文涉及的代码可以到Github项目loopback-hello-world下载。