文章目录
- Typescript 基础
- Typescript 安装
- TypeScript 问题
- 最简单的改造
- Sails重定义
- Waterline(Orm)
- 重写Models
- Typescript 重写控制器
- User Model的进一步优化
- 前后端约定
- 路径别名
- tsconfig.json
- module-alias
- 安装
- 使用
- Jest测试
Typescript 基础
Typescript 安装
Sails推荐的编程语言是javascript,但是Typescript的强类型的确会让编程过程变得更加愉快一点。
- 要使用Typescript,首先需要安装三个库
npm install typescript ts-node --save
npm install @types/node --save
npm install @types/express --save
npm 安装后面加–save参数,可以把安装的依赖库保存到package.json里面
- 安装完ts之后,需要执行初始化,帮助生成ts配置文件
npx tsc --init
执行之后,在项目根目录下面会多出一个tsconfig.json文件
3. 配置tsconfig.json,更多的配置可以查看TS官网文档
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"paths": {
"@/*": ["./api/*"],
"@/typing/*": ["./typing/*"],
},
"allowJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"suppressImplicitAnyIndexErrors": true,
"skipLibCheck": true
},
"include": [
"api/**/*",
"api/*",
"typing/**/*",
"typing/*",
],
"exclude": ["node_modules", "build", "dist", "scripts", ".tmp/*"]
- 最后,在app.js里面添加如下语句
require('ts-node/register');
- 使用Ts之后,不再使用sails lift,需要改成node app.js
TypeScript 问题
1、tsconfig.json
有时候,tsconfig.json文件在VsCode编译器里面会出现标红(不影响使用),提示错误如下:
报错内容:JSON schema for the TypeScript compiler’s configuration file,无法写入文件“xxxx/xxx.js”,因为它会覆盖输入文件。这种情况虽然不影响使用,但是会害死强迫症,可以用如下方式解决:
在tsconfig.json文件的配置中添加配置,保存配置文件后重启vscode就可以了
"compilerOptions": {
"outDir":"dist"
}
- tsc 编译并测试(需要依赖tsconfig.json)
如果有需要,需要手动编译对.ts文件为js文件,可以使用tsc进行编译
tsc -p . //-p 指明tsconfig.json的文件路径 "."表示配置文件在当前目录
node dist/test/test.js //dist是tsconfig.json里面指定outdir的目录,test/test.js是要测试的js文件
3、typescript 用索引访问对象问题
有时候需要用object[key]的方式访问未知对象key的属性,类似下面的代码
for (const key in obejct) {
// 处理...
obejct[key]
....
}
在ts里面,如果是any类型的时候,会报错如下:
元素隐式具有 “any“ 类型,因为类型为 “string“ 的表达式不能用于索引类型 “Object“。 在类型 “Object“ 上找不到具有类型为 “string“ 的参数的索引签名
解决方法是:
在tsconfig.json中compilerOptions里面新增忽略的代码,如下所示,添加后则不会报错
"suppressImplicitAnyIndexErrors": true
最简单的改造
前面的准备工作都做好了之后,我们已经可以用ts写一个控制器了,类似代码如下:
export function hello(req:any, res:any, next: Function):any {
res.status(200).send('Hello from Typescript!');
}
大家可以和我们原来的控制器做个比较,我们发现区别不是很大,module.exports 改成export,其它都是any类型。any类型是一种最懒惰的类型,如果只是这样的“强”类型,对我们后面的工作其实是没有什么意义的。我们希望的是能够正在在我们使用变量的时候能够得到类型提醒和受到类型约束。目前的这种改造显然是不够的。
Sails重定义
要达到真正对后面的编程有强类型约束,我们需要做两个事情,一个是把sails里面的waterline(ORM)这部分消化掉,并且写成相关联的typing(.d.ts)。一个是要把api的请求(request)和响应(response)消化掉,写出相应的interface
为了规划整个App的文件结构,我们在根目录下面新建一个typing文件夹,用来存放这个App所需要的类型定义
Waterline(Orm)
根据Sails文档,我们所有数据模型Model都是以Waterline捆绑在一起的,当我们写了一个user的model的时候,实际上我们继承了waterline已经多好的许多功能,比如create函数,attribute属性等等。
创建一个model的时候,我们需要提供这个model的attributes(相当于数据库中的字段信息),我们还需要继承waterline已经做好的功能函数比如create,find等。然后我们使用一个model的时候,还需要提供它作为对象本身的可以在js里面直接操作的属性(object的key)
- 关于attribute的定义
interface AttributeObject {
/**
* 类型
*/
type: 'string' | 'number' | 'boolean' | 'json' | 'ref',
/**
* 默认值
*/
defaultsTo?: any,
/**
* 是否自动增长
*/
autoIncrement?: boolean,
/**
* 唯一性
*/
unique?: boolean,
/**
* 是否允许空值
*/
allowNull?: booleab,
/**
* 是否加密字段,如果设置true,改字段自动加密
*/
encrypt?: boolean,
/**
* 索引
*/
index?: boolean,
/**
* 主键
*/
primaryKey?: boolean,
enum?: any[],
size?: number,
/**
* 字段名称
*/
columnName?: string,
/**
* 字段类型,如果没有指定,自动根据type类型转换成数据库类型
*/
columnType?: string,
special?: boolean,
model?: string;
collection?: string;
via?: string;
dominant?: boolean;
}
- 常用函数定义
/**
* 模型函数
*/
interface ModelMethods<instance extends ModelInstance> {
destroy(): Promise<this>;
toJSON(): any;
save(): WaterlinePromise<this>;
create(params: instance): WaterlinePromise<instance>;
create(params: any): WaterlinePromise<instance>;
create(params: any[]): WaterlinePromise<instance>;
find(): QueryBuilder<instance[]>;
find(criteria: any): QueryBuilder<instance[]>;
findOne(criteria: any): QueryBuilder<instance>;
findOne(criteria: string): QueryBuilder<instance>;
findOne(criteria: number): QueryBuilder<instance>;
findOne(): QueryBuilder<instance>;
count(criteria: any): WaterlinePromise<number>;
count(criteria: any[]): WaterlinePromise<number>;
count(criteria: string): WaterlinePromise<number>;
count(criteria: number): WaterlinePromise<number>;
destroy(criteria: any): WaterlinePromise<instance[]>;
destroy(criteria: any[]): WaterlinePromise<instance[]>;
destroy(criteria: string): WaterlinePromise<instance[]>;
destroy(criteria: number): WaterlinePromise<instance[]>;
update(criteria: any, changes: any): WaterlinePromise<instance[]>;
update(criteria: any, changes: any[]): WaterlinePromise<instance[]>;
update(criteria: any[], changes: any): WaterlinePromise<instance[]>;
update(criteria: any[], changes: any[]): WaterlinePromise<instance[]>;
update(criteria: string, changes: any): WaterlinePromise<instance[]>;
update(criteria: string, changes: any[]): WaterlinePromise<instance[]>;
update(criteria: number, changes: any): WaterlinePromise<instance[]>;
update(criteria: number, changes: any[]): WaterlinePromise<instance[]>;
query(sqlQuery: string, cb: (err: Error, results: any[]) => void);
native(cb: (err: Error, collection: any) => void);
stream(criteria: any, writeEnd: any): NodeJS.WritableStream;
stream(criteria: any[], writeEnd: any): NodeJS.WritableStream;
stream(criteria: string, writeEnd: any): NodeJS.WritableStream;
stream(criteria: number, writeEnd: any): NodeJS.WritableStream;
stream(criteria: any, writeEnd: any): Error;
stream(criteria: any[], writeEnd: any): Error;
stream(criteria: string, writeEnd: any): Error;
stream(criteria: number, writeEnd: any): Error;
}
以上代码的完整版详见源代码里面typing文件夹中的Orm.d.ts,Api.d.ts 两文件
- 我们还需要重新定义好request和response,大致代码如下:(详见源代码)
import UpstreamModel from "./UpstreamModel";
/**
* Request和Response等Api操作
*/
declare module Api {
interface SailsRequest {
url: string;
baseUrl: string;
originalUrl: string;
method: string;
headers: Dictionary<string>;
body: any;
hostname: string;
params: any;
writable: boolean;
allowHalfOpen: boolean;
method: string;
params: any;
query: Dictionary<string>;
file(field: string): UpstreamModel.Upstream;
}
interface ResponseHandlerFn {
(data: any, pathToView?: string): void;
}
interface Response {
badRequest: ResponseHandlerFn;
forbidden: ResponseHandlerFn;
negotiate: (err: any) => void;
notFound(data: any, pathToView?: string): void;
notFound(): void;
created(instance: any): void;
send: ResponseHandlerFn;
ok: ResponseHandlerFn;
redirect: ResponseHandlerFn;
status(statusCode: 200 | 101 | 301 | 302 | 400 | 401 | 403 | 404 | 500): Response;
json: ResponseHandlerFn;
location: ResponseHandlerFn;
clearCookie: ResponseHandlerFn;
serverError: ResponseHandlerFn;
view(pathToView: string): void;
view(pathToView: string, locals: any): void;
view(locals: any): void;
view(): void;
}
}
export = Api;
以上代码的完整内容详见源代码typing文件夹内Api.d.ts QueryOptions.d.ts UpstreamModel.d.ts 三文件
这些都是根据Sails.js官网上面的文档设计出来的,并不完整,我们可以一边开发一边完善。
更多的内容参考https://sailsjs.com/documentation/reference
更多内容没法写在教程里面,请自行参考github上的源代码
重写Models
- 定义user的数据类型
在typing 文件夹中做好waterline基础类型定义之后,我们可以在这个基础上做用户数据定义,在typing中新增UserModel.d.ts,新建UserModel模块,代码如下:
import Orm from "./Orm";
module UserModel {
/**
* 这个地方主要是实例参数
*/
export interface UserInstanceProps extends Orm.ModelInstance {
email: string;
password?: string;
}
/**
* 实例的属性
*/
interface UserAttributes extends UserInstanceProps, Orm.AttributeCollection {
email: Orm.Attribute;
password: Orm.Attribute;
}
/**
* 实例定义,用于控制器操作包含实例参数和相关操作函数
*/
export interface UserInstance extends UserInstanceProps, UserMethods { }
/**
* 实例的所有函数
*/
export interface UserMethods extends Orm.ModelMethods<UserInstanceProps> {
populateCustom?(rawValues: UserInstance): Promise<UserInstance>;
encryptPassword?(values: { password: string, hashedPassword: string }, cb: Function): void;
}
/**
* 模型定义,和数据库对应,主要用于Model里面的属性定义attributes。
*/
export interface UserDefs extends Orm.ModelDefinition<UserInstanceProps, UserAttributes> { }
}
export = UserModel;
- 在api/models文件夹中删除user.js,新增User.ts,并在User.ts中添加一个email和password两个属性(相当于数据库的字段),具体如下:
import UserModel from "typing/UserModel";
let User: UserModel.UserDefs = {
attributes: {
email: { type: 'string'},
password: { type: 'string'},
}
}
export = User;
Typescript 重写控制器
- 手写控制器
因为采用TS,不能再使用Sails命令创建控制器。手写控制器需要按照约定:首字母大写+Controller.ts。还是以用户操作为例,把原来添加的UserController.js删除掉,在controllers里面添加新文件并命名为UserController.ts - 在UserController.ts里面,添加查询语句
import UserModel from "typing/UserModel";
import Api from "typing/Api";
declare var sails: any;
/**
* 查询
* @param req
* @param res
* @param next
*/
export async function retrieve(req: Api.SailsRequest, res: Api.Response, next: Function): Promise<any> {
let User = <UserModel.UserInstance>sails.models.user;
let rows = await User.find({});
res.status(200).send(rows);
}
export async function create(req: Api.SailsRequest, res: Api.Response, next: Function): Promise<any> {
let User = <UserModel.UserInstance>sails.models.user;
let Props: UserModel.UserInstanceProps = {
email: req.body.email,
password: req.body.password
}
if (!Props.email) res.serverError("email 不能空");
else {
try {
let rows = await User.create(Props).fetch();
res.status(200).send(rows);
} catch (error) {
res.serverError(error);
}
}
};
本代码里面,declare var sails:any; 正是用来获取sails库,通过sails.models可以获取定义的所有数据模型,在通过<>类型强制转换,我们就可以直接调用sails已经做好的诸如create,find等函数。
有了TS的数据定义,现在写代码有提示了,并且如果不符合要求,还会出现编译错误,这才是我们改造Typescript的目的
- 添加路由,并用Postman测试
config/routes.js里面新增如下代码:
'POST /api/userCreate': { action: 'User/create' },
'POST /api/userRetrieve': { action: 'USer/Retrieve' },
用Postman测试新增用户Api结果如下:
用Postman测试用户查询结果如下:
User Model的进一步优化
如果是其它表的操作,这样也许就可以了,但是对于用户表,现在的新增和查询暴露出两个问题。一是新增的时候,我们并没有对email进行唯一性检查;二是查询的时候用户的密码直接返回给前端,并且用户密码没有加密。这样的设计显然是不合格的。
- 唯一性,对于email,我们需要把它设置为不可重复。这需要我们把aip/models/User.ts 中attributes里面的email设置为unique:true
- 明文密码的问题,需要修改attributes里面的password为encrypt:true
- 查询用户信息的时候,删除password,可以通过重载customToJSON函数
为此,改造User.ts如下:
import UserModel from "typing/UserModel";
declare var _: any;//获取lodash这个操作库
let User: UserModel.UserDefs = {
attributes: {
email: { type: 'string', unique: true },
password: { type: 'string', encrypt: true },
},
customToJSON:function():any {
return _.omit(this, ['password']);//使用lodash库删除password属性,也可以直接用delete
}
}
export = User;
改造之后,多次添加用户信息,第一次可以成功,第二次添加同样email的用户,就会返回唯一性错误。而这些都是sails帮我做好了,我们只是做了一个配置上的修改而已。并且因为有了对customToJSON的重载,成功添加的时候,也不会把密码返回给前端了。
再次测试查询api如下图,可以看到返回到前端的password已经没有了
前后端约定
作为前后端分离的开发模式,前端的开发比较专注用户UI方面,数据库的查询应该是后端的工作。让前端的开发人员去学习Sails的数据库查询是不合适的。但是Sails的查询,修改和更新又是有一定的学习门槛的,因此我们需要后端做一些查询改造,让前端人员可以轻松的调用Api实现大多数数据库查询,并且需要做好约定,确保大家查询格式一致。比如我们可以约定好,前端把要查询的条件用一个Object传递过来就好(这样的操作对前端是比较容易实现的),具体约定大致如下:
前端只给出key:value,后端整理成waterline的QueryOptions
约定如下:
* 1、每一组key:value键值代表一个查询。
* 比如 {age:18}代表要查询的条件是age=18
* 2、后端根据key对应属性,如果是字符型,默认为contains操作,即包含给定值的模糊查询
* 比如 {name:'cai'} 表示要查询的是name里面包含'cai'的数据name like '?i%'
* 3、多组键值对默认都是与(and)的关系
* 比如 {name:'cai',age:18} 相当于name like '?i%' and age=18
* 4、每一组key:value中,key用于后端判断数据类型,应该与要查询的表对应字段名称一致,如果不同,判断为string类型
* 5、前端的value里面如果出现$,那么$前面是操作符后面是值,比如">$3",表示大于3
* 6、如果字符型需要精确查找,应该多一个'=='操作符
* 比如 {name:'==$cai'} 相当于name = 'cai'
* 7、操作符只能是如下的值 '!=' | '<' | '<=' | '>' | '>=' | 'nin' | 'in' | 'contains' | 'startsWith' | 'endsWith' |'==';
* 8、多个键值对如果是or关系,需要写成数组,用"or"作为key
* 比如 {name:'cai',or:[{age:18},{name:'li'}]} 相当于(name like '?i%') or (age=18 and name like '%li%')
* 9、还有不能满足要求的,调用waterline的sendNativeQuery执行原始sql语句,这个需要另行约定
为此,我们需要做一个可以根据model属性把前端传过来的查询条件转换成Waterline的Criteria的功能,实现过程详见源代码utils/Criteria.ts
路径别名
软件开发过程中,经常会出现需要把一些常用的功能代码独立出来,并放在类似helper或utils之类的文件夹中,sails在api文件夹的结构里面有一个helper文件夹,我认为放在根目录会更合理一些,所以我再根目录创建了一个utils文件夹。比如《前后端约定》这节内容里面,涉及到一个查询条件的转换,我把它写成一个公共函数放在utils/Criteria.ts,然后我再UserController.ts控制器里面需要用到这个功能,这个时候就需要import这个公共函数,代码类似这样:
import UserAttributes from "../models/User";
import { getByAttributes } from "../../utils/Criteria";
这种import方式,使用的是相对路径,这是一种比较难受并且易错的做法。
tsconfig.json
可以在tsconfig.json配置文件里的paths设置路径别名,如下:
"compilerOptions": {
...
"paths": {
"api/*": [
"api/*"
],
"utils/*": [
"utils/*"
],
"typing/*": [
"typing/*"
],
"swagger/*": [
"swagger/*"
]
},
},
....
修改后,我们的import语句可以改成这样:
import UserAttributes from "api/models/User";
import { getByAttributes } from "utils/Criteria";
鼠标移到api/models/users上面,还可以看到如下提示
tsconfig.json里面还有一个 “include”: []和"exclude":[] 分别是设置需要把ts编译成js的路径和不要编译的路径
module-alias
按照上的操作,我们可以解决ts文件里面的引用问题,并且vscode里面不会有错误提示了。但是如果这个时候我们运行node app.js启动服务,终端上面还是会出现找不到utils/Criteria等提示。
这是因为tsconfig.json是为vscode支持ts文件服务的,当所有ts文件转换为js文件之后,import路径是给nodejs使用的,它还是会找不到。这个如果有用到webpack,它的alisa也可以解决这个问题。后端没有webpack的情况下,可以用module-alisa这个库来解决这个问题。
安装
npm i --save module-alias
使用
安装后,在package.json里面添加"_moduleAliases" 节点就可以,package.json 大致是这样的:
....
"_moduleAliases": {
"api":"./api",
"utils": "./utils",
"swagger":"./swagger"
},
"scripts": {
"start": "NODE_ENV=production node app.js",
"test": "jest",
"lint": "./node_modules/eslint/bin/eslint.js . --max-warnings=0 --report-unused-disable-directives && echo '✔ Your .js files look good.'",
"custom-tests": "echo \"(No other custom tests yet.)\" && echo"
},
....
修改完package.json之后,还需要在启动的时候,引入系统。在app.js里面添加 require(‘module-alias/register’); 添加后的app.js代码片段如下:
..... 略
process.chdir(__dirname);
require('module-alias/register');//集成module-alias别名功能
var sails;
var rc;
..... 略
保存后重新启动服务,编译错误不再出现。
Jest测试
在开发过程中,因为sails做为后端,每个控制器实现的功能都需要通过启动sails之后,再通过Postman进行测试。然而在开发期间,有时候我们只是想要测试一下某个函数是否能够达到我们设计的目的,或是想要console.log出某个sails内置的某个全局变量,这种情况每次重启sails的效率就比较低,并且没法实现自动化测试。为此我们需要引入测试库。Jest 是一个令人愉快的 JavaScript 测试框架,专注于 简洁明快。具体可以查看:https://www.jestjs.cn/
为实现开发过程测试和后面的自动化测试,我们需要
- 安装Jest
npm install --save-dev jest
- 在package.json里面添加测试脚本
{
"scripts": {
"test": "jest"
}
}
- 安装jest的Typescript 预编译库 ts-jest,在nodejs里面执行命令如下:
npm i ts-jest --save
- 执行ts-jest 初始化命令如下:
npx ts-jest config:init
执行成功后,可以在项目根目录下看到 jest.config.js
- 在根目录里面添加test文件夹,新增criteria.test.ts,并添加如下测试代码
import User from "../api/models/User";
import { getByAttributes } from "../utils/Criteria";
test('根据前端post的body 解析成find要的格式', () => {
let body: any = {
email:'zz'
};
let res = getByAttributes(User.attributes, body);//调用要测试的函数
console.log(JSON.stringify(res));//输出函数返回结果
expect(res).not.toBe(false);//判断测试是否通过
});
- 在Visual Studio Code里面多开一个终端,并执行
npm run test
运行如下:
也可以使用命令行,实现单个功能测试,具体见https://www.jestjs.cn/docs/getting-started
- npm test 指令会测试整个根目录下面test子目录里面的所有测试单元,有时候我们只是想要测试某个测试文件,比如sqlTypes.test.ts文件,这个时候也可以执行单个文件测试。
npm test sqltypes
npm test 后面跟的是test文件夹里面的测试文件,可以忽略大小写,可以忽略.test.ts 扩展名