本文内容承接本系列的上一篇 《写给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 。我们先介绍目前为止项目中用到的基础类型,其余的在后续中碰到时再做详细介绍,大家也可以到官方文档的中查询所有的 基础类型 。
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。
TypeScript不再区分int、long、double等这些数字类型,所有的数字都属于浮点数类型 number
。
TypeScript中的布尔值类型 boolean
与Java中的定义一样,包含 true
/ false
两种值。
与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
的典型用法为:
本文是《写给Java程序员的TypeScript入门教程》系列的第二篇,主要介绍了 云服务结算系统 的domain层设计与实现,包括 领域建模 和 代码实现 。在介绍代码实现的过程中,穿插介绍了一些TypeScript的特性,主要包括类、接口、基础类型这三类。TypeScript很多特性跟Java比较类似,因此作为Java开发者,入门TypeScript相对来说难度并不大。本文只是介绍了TypeScript中一些最最基础的特性,更多的特性需要在进行实际开发工作时通过查阅官方文档获得。
更多深入的内容,请关注后续的篇章。