本文内容承接本系列的上一篇《写给Java程序员的TypeScript入门教程(一)》。上一篇介绍了本系列教程的背景,并进行了开发环境的搭建。本系列的教学思路是通过项目实战来学习TypeScript,选取了一个简单的云服务结算系统作为实战项目,该系统的主要功能以及代码分层已经在上一篇中介绍过。本文内容主要介绍云服务结算系统的domain层,具体分为领域建模和代码实现两方面,在其中会穿插对TypeScript的讲解。
本教程教学项目的代码都放在了github项目: typescript-tutorial-for-java-coder
domain层就是所谓的领域层,在领域驱动设计中,该层主要实现了系统的一些核心业务逻辑(与具体实现无关,比如数据交互的协议、数据存储的数据库等)。领域建模就是对领域层的一些通用概念进行模块设计,让代码能够更清晰地表达业务逻辑。领域建模不是TypeScript独有的,它是软件设计开发的一种方法论,是降低复杂系统理解难度的一种有效手段。领域建模可以使代码的模块结构更加清晰,这无疑很适合TypeScript,因为TypeScript被设计出来的一个目的就是为了改善JavaScript模块结构混乱。
本文会简单介绍云服务结算系统的领域建模过程,方便大家有更好的代入感。本系列的是TypeScript的入门教程,并不会深入介绍领域建模相关知识。领域驱动设计是一个很好的软件开发思想,后面会有专门的系列详细介绍。
在进行领域建模之前,首先需要把系统的通用语言列出来,所谓通用语言就是系统的业务逻辑常用的用语。列出通用语言对领域建模有很大的帮助,特别是在系统业务复杂到难以下手进行建模时。通过对一个个通用语言进行建模,分而治之,慢慢地,整个系统就清晰了。
以下列出了云服务结算系统的一些通用语言,需要特别注意的是,通用语言并不是一成不变的,它会随着项目的进程不断调整。
建模的过程就是把通用语言转化为程序语言(这里就是TypeScript)的过程。这个过程中,面向对象的思想很重要,只有把概念都封装好,整个模块的结构才会整洁清晰。在领域驱动设计里面有这么几个概念:值对象(Value Object)、实体(Entity)、领域服务(Service)、资源库(Repository)和聚合(Aggregate)。
根据这些概念的定义,我们对前一节的通用语言进行建模,得出如下UML图。
因为 CloudService 的结算策略是一个经常变化的方向,因此将它建模成一个接口 ChargingStrategy,本教程只提供了两种实现:ChargingPerUsageStrategy(按需计费)和 ChargingPerPeriodStrategy(按周期计费)。另外,User 即是一个实体,也是一个聚合,购买云服务和结算的业务逻辑都放在了 User 上。
domain层的实现代码在github项目 typescript-tutorial-for-java-coder 上的src/domain/
目录下。
首先看一下值对象 Id 的具体实现:
// src/domain/id.ts
import {v1 as uuid} from 'uuid';
// 唯一标识ID值对象
export class Id {
private readonly _val: string;
private constructor(val: string) {
this._val = val;
}
// 工厂方法
static of(val: string): Id {
return new Id(val);
}
// 返回一个随机的ID,值为UUID
static random(): Id {
const val = uuid();
return new Id(val);
}
get val(): string {
return this._val;
}
}
TypeScript类的语法和Java的很类似,比如上述代码我们声明了一个名为 Id 的类,它有一个私有属性_val
、一个私有的构造函数constructor
、两个静态工程方法of
和random
、一个get
方法val
。
TypeScript的类有三种修饰符,分别是公开public
、私有private
和受保护protected
,当类中的成员不指定修饰符时,默认为public
。
与TypeScript相比,Java类的成员如果不指定修饰符,默认为包内可见。
TypeScript与Java有一个很明显的区别就是,变量/返回值的类型声明是跟在变量/函数的后面的。如private readonly _val: string
的意思是声明一个私有的不可变成员_val
,类型为string
。值得注意的是,在类中声明一个成员为不可变时需要使用readonly
来进行限定。
TypeScript中的构造函数统一使用constructor
进行声明,而不是使用类名,这一点与Java有着明显的不同。与Java类似,TypeScript中也有静态成员,使用static
进行限定,访问时通过类名进行访问。
const id = Id.of('test-id');
expect(id.val).toEqual('test-id');
使用静态工厂方法来创建实例可以让代码可读性更好,而且让创建对象的逻辑与对象使用者解耦。比如后续如果需要把类改成单例模式,只需修改静态工厂方法实现即可,对象的使用者无需做任何变动。
Java程序员一定对getter/setter函数不陌生,在TypeScript里,getter/setter函数变成了语言本身的一种特性,声明时需要在函数前面加上get
或set
,调用时跟访问类的公开成员类似。
class Id {
...
// 声明get函数
get val(): string {
return this._val;
}
// 声明set函数
get val(newVal: string): void {
this._val = newVal;
}
...
}
// 使用例子
const tmpVal = id.val; // 调用get val()函数
id.val = '12345' // 调用set val(newVal: string)函数
TypeScript也是通过import
来引入其他模块,但具体的语法和Java有细微的差别,不过这都可以通过WebStorm进行自动导入,无需过多操心。
export
为导出的语义,与Java不同,在TypeScript中,如果在需要让一个类、函数、变量在另一个模块/文件中可见,需要在声明时加上export
。
// 表示其他模块/文件可以引入Id这个类
export class Id {...}
Id 类中定义了一个私有成员_val
,其类型为string
。在TypeScript中,string属于基本类型,同属的还有boolean、number、数组、元组、枚举、any、void、null、undefined、never、Object。我们先介绍目前为止项目中用到的基础类型,其余的在后续中碰到时再做详细介绍,大家也可以到官方文档的中查询所有的基础类型。
字符串 string
TypeScript可以使用双引号( "
)或单引号('
)表示字符串。string有个很好的特性——模板字符串,这种字符串是被反引号包围(` ),并且以${ expr }
这种形式嵌入表达式。
let name: string = 'Gene';
let age: number = 37;
let sentence: string = `Hello, my name is ${ name }. I'll be ${ age + 1 } years old next month.`;
// sentence的值为 Hello, my name is Gene. I'll be 38 years old next month.
Java中使用双引号表示字符串类型String,单引号表示字符类型char。
数字 number
TypeScript不再区分int、long、double等这些数字类型,所有的数字都属于浮点数类型number
。
布尔值 boolean
TypeScript中的布尔值类型boolean
与Java中的定义一样,包含true
/false
两种值。
void
与Java类似,void
表示没有任何类型,当一个函数没有返回值时,其返回类型就是void
。而声明一个变量为void
类型没有什么意义,因为只能赋值为null
或undefined
。
其他值对象Telephone、Fee、Usage基本上也只用到了上述几个基本的TypeScript特性,代码不在本文贴出,具体实现可到github项目上查看。
本节只介绍 CloudService 实体,User 实体放到聚合实现一节介绍,CloudService的实现如下:
// src/domain/cloud-service.ts
// 云服务 实体
export class CloudService {
// 用户购买的云服务唯一标识
private readonly _id: Id;
// 云服务名
private readonly _name: string;
// 云服务的结算策略
private readonly _chargingStrategy: ChargingStrategy;
... // 私有构造函数
// 静态工厂方法
static of(name: string, chargingStrategy: ChargingStrategy, id?: Id): CloudService {
// 如果没有传入Id值,则赋值UUID
if (!id) {
id = Id.random();
}
return new CloudService(name, chargingStrategy, id);
}
// 对资源使用量进行结算结算
charging(usage: Usage): Fee {
return this._chargingStrategy.charging(usage);
}
... // get、set函数
}
在CloudService的静态工厂方法的入参id
后面跟了一个?
,这个是TypeScript函数的可选参数用法。当调用者没有传递id
这个参数时,id
的值为undefined
。
// 指定Id
let cloudService = CloudService.of('HBase', strategy, Id.of('123'));
expect(cloudService.id.val).toEqual('123');
// 不指定Id
cloudService = CloudService.of('HBase', strategy);
console.log(cloudService.id.val) // 输出一个UUID
**CloudService ** 的私有属性_chargingStrategy
的类型是 ChargingStrategy,它是一个接口,其定义和实现类如下:
// src/domain/charging-strategy.interface.ts
// 结算策略抽象接口
export interface ChargingStrategy {
/**
* 对云服务的使用量进行计费.
* @param usage 云服务对应对资源使用量
* @return 需付金额
*/
charging(usage: Usage): Fee;
}
// src/domain/charging-per-usage-strategy.ts
// 按需计费策略,实现ChargingStrategy接口
export class ChargingPerUsageStrategy implements ChargingStrategy {
// 单价
private readonly _price: number;
... // 构造函数与静态工程方法
charging(usage: Usage): Fee {
// 单价*使用量
const fee = this._price * usage.val;
return Fee.of(fee);
}
}
从这个例子看,TypeScript中的接口与Java中的接口很类似,使用interface
进行声明,接口中的函数只声明,具体实现放到实现类上。
除了函数之外,TypeScript还支持在接口中声明属性,这是Java接口所不支持的。
// 接口SquareConfig声明了两个属性
export interface SquareConfig {
color: string;
width: number;
}
// 实现接口
let config: SquareConfig = {color: 'red', width: 50};
expect(config.color).toEqual('red');
expect(config.width).toEqual(50);
上述例子中,接口的实现并没有像 ChargingPerUsageStrategy 这样创建一个子类,而是采用了类似Java里面通过lambda表达式匿名实现接口的手法:{field1: implementation, ...}
。后面我们将看到,接口里面的函数也支持这种手法进行匿名实现。
在domain层中,资源库(Repository)只给出接口,不提供具体实现。因为领域层应该只关系系统的业务逻辑,至于一些涉及到具体实现(如数据库持久化)的代码应该放到基础设施层上。
本节值介绍 CloudServiceRepository 的定义,UserServiceRepository 的定义与CloudServiceRepository类似,具体可以到github项目上查看。
// src/domain/cloud-service-repository.ts
export interface CloudServiceRepository {
// 保存云服务
save(cloudService: CloudService, userId: Id): boolean;
// 删除云服务
delete(cloudService: CloudService): boolean;
// 根据云服务ID查找
findById(cloudServiceId: Id): CloudService;
// 根据用户ID查找
findByUserId(userId: Id): CloudService[];
}
TypeScript中数据的声明与Java中的数据声明类是,都是type[]
的形式,定义时稍微不同,TypeScript在定义数组时通过[]
将元素括起来,而Java则是使用{}
。
let list: number[] = [1, 2, 3];
因为在domain层中资源库没有具体实现,在进行单元测试时,依赖了资源库的类要怎么测试呢?这时就可以采用前面提到的匿名实现手法。
const repository: CloudServiceRepository = {
save: (cloudService, userId) => true,
delete: (cloudService) => true,
findById: (serviceId) => null,
findByUserId: (userId) => [],
};
可以看到,函数的匿名实现很像Java里面的lambda表达式,在TypeScript里面,箭头不再是->
,而是=>
。
此外,还可以只实现部分函数,只需在前一行加上@ts-ignore
,这样就可以减少单元测试的多余实现了。
// @ts-ignore
const repository: CloudServiceRepository = {
save: (cloudService, userId) => true,
};
业务代码中并不推荐这样实现,这正是TypeScript相对JavaScript有所改进的地方,增加了静态检查,减少Bug的出现。
User 即是一个实体,也是一个聚合,实现了购买云服务和结算的业务逻辑。
export class User {
// 用户唯一标识
private readonly _id: Id;
// 用户名
private readonly _name: string;
// 用户联系方式
private readonly _phone: Telephone;
// 云服务仓库
private readonly _serviceRepository: CloudServiceRepository;
... // 构造函数和静态工厂方法
// 购买云服务.
buy(cloudService: CloudService): boolean {
return this._serviceRepository.save(cloudService, this._id);
}
// 判断用户是否已经购买了这个云服务.
hasBuy(serviceId: Id): boolean {
return this._serviceRepository.findById(serviceId) != null;
}
// 对云服务使用量进行结算.
settle(service: CloudService, usage: Usage): Fee {
return service.charging(usage);
}
... // get、set函数
}
在上述代码的hasBuy
函数的实现中,我们通过将findById
的返回值与null
进行比对来判断是否找到指定id的 CloudService 对象。
在TypeScript中,null
和undefined
也属于基本类型,它们的值只能是null
和undefined
。默认情况下null
和undefined
是所有类型的子类型。 就是说你可以把 null
和undefined
赋值给number
类型的变量。但是,当指定了--strictNullChecks
标记时,null
和undefined
只能赋值给void
和它们各自。
那么,两者又有什么区别呢?
null表示"没有对象",即该处不应该有值,转为数值时为0;undefined表示"缺少值",就是此处应该有一个值,但是还没有定义,转为数值时为NaN。
null
的典型用法为:
- 作为函数的参数,表示该函数的参数不是对象。
- 作为对象原型链的终点。
undefined
的典型用法为:
- 变量被声明了,但没有赋值时,就等于undefined。
- 调用函数时,应该提供的参数没有提供,该参数等于undefined。
- 对象没有赋值的属性,该属性的值为undefined。
- 函数没有返回值时,默认返回undefined。
本文是《写给Java程序员的TypeScript入门教程》系列的第二篇,主要介绍了云服务结算系统的domain层设计与实现,包括领域建模和代码实现。在介绍代码实现的过程中,穿插介绍了一些TypeScript的特性,主要包括类、接口、基础类型这三类。TypeScript很多特性跟Java比较类似,因此作为Java开发者,入门TypeScript相对来说难度并不大。本文只是介绍了TypeScript中一些最最基础的特性,更多的特性需要在进行实际开发工作时通过查阅官方文档获得。
更多深入的内容,请关注后续的篇章。