写给Java程序员的TypeScript入门教程(二)

本文内容承接本系列的上一篇《写给Java程序员的TypeScript入门教程(一)》。上一篇介绍了本系列教程的背景,并进行了开发环境的搭建。本系列的教学思路是通过项目实战来学习TypeScript,选取了一个简单的云服务结算系统作为实战项目,该系统的主要功能以及代码分层已经在上一篇中介绍过。本文内容主要介绍云服务结算系统domain层,具体分为领域建模代码实现两方面,在其中会穿插对TypeScript的讲解。

本教程教学项目的代码都放在了github项目: typescript-tutorial-for-java-coder

1 domain层领域建模

domain层就是所谓的领域层,在领域驱动设计中,该层主要实现了系统的一些核心业务逻辑(与具体实现无关,比如数据交互的协议、数据存储的数据库等)。领域建模就是对领域层的一些通用概念进行模块设计,让代码能够更清晰地表达业务逻辑。领域建模不是TypeScript独有的,它是软件设计开发的一种方法论,是降低复杂系统理解难度的一种有效手段。领域建模可以使代码的模块结构更加清晰,这无疑很适合TypeScript,因为TypeScript被设计出来的一个目的就是为了改善JavaScript模块结构混乱。

本文会简单介绍云服务结算系统的领域建模过程,方便大家有更好的代入感。本系列的是TypeScript的入门教程,并不会深入介绍领域建模相关知识。领域驱动设计是一个很好的软件开发思想,后面会有专门的系列详细介绍。

1.1 通用语言

在进行领域建模之前,首先需要把系统的通用语言列出来,所谓通用语言就是系统的业务逻辑常用的用语。列出通用语言对领域建模有很大的帮助,特别是在系统业务复杂到难以下手进行建模时。通过对一个个通用语言进行建模,分而治之,慢慢地,整个系统就清晰了。

以下列出了云服务结算系统的一些通用语言,需要特别注意的是,通用语言并不是一成不变的,它会随着项目的进程不断调整。

写给Java程序员的TypeScript入门教程(二)_第1张图片

1.2 建模

建模的过程就是把通用语言转化为程序语言(这里就是TypeScript)的过程。这个过程中,面向对象的思想很重要,只有把概念都封装好,整个模块的结构才会整洁清晰。在领域驱动设计里面有这么几个概念:值对象(Value Object)、实体(Entity)、领域服务(Service)、资源库(Repository)和聚合(Aggregate)。

  • 值对象:一些没有唯一标识的简单对象,常常是不可变的,如果需要修改就整个对象替换掉,如电话。
  • 实体:在整个系统中具有唯一标识的对象,如用户。
  • 领域服务:当系统中一些业务逻辑不适合放在值对象或实体中时,就可以建模为领域服务。
  • 资源库:用于值对象或实体的持久化存储,在领域层中往往是一个抽象接口,具体实现放在基础设施层。
  • 聚合:领域对象的组合,用于封装业务,并保证聚合内领域对象的数据一致性。

根据这些概念的定义,我们对前一节的通用语言进行建模,得出如下UML图。

写给Java程序员的TypeScript入门教程(二)_第2张图片

  • 值对象:Id(唯一标识符)、Telephone(联系电话)、Fee(金额)、Usage(资源使用量)
  • 实体:User(用户)、CloudService(云服务)
  • 资源库:UserRepository(用户资源库)、CloudServiceRepository(云服务资源库)
  • 聚合:User(用户)

因为 CloudService 的结算策略是一个经常变化的方向,因此将它建模成一个接口 ChargingStrategy,本教程只提供了两种实现:ChargingPerUsageStrategy(按需计费)和 ChargingPerPeriodStrategy(按周期计费)。另外,User 即是一个实体,也是一个聚合,购买云服务和结算的业务逻辑都放在了 User 上。

2 domain层实现

domain层的实现代码在github项目 typescript-tutorial-for-java-coder 上的src/domain/目录下。

2.1 值对象

首先看一下值对象 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特性——类

TypeScript类的语法和Java的很类似,比如上述代码我们声明了一个名为 Id 的类,它有一个私有属性_val、一个私有的构造函数constructor、两个静态工程方法ofrandom、一个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函数变成了语言本身的一种特性,声明时需要在函数前面加上getset,调用时跟访问类的公开成员类似。

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和export

TypeScript也是通过import来引入其他模块,但具体的语法和Java有细微的差别,不过这都可以通过WebStorm进行自动导入,无需过多操心。

export为导出的语义,与Java不同,在TypeScript中,如果在需要让一个类、函数、变量在另一个模块/文件中可见,需要在声明时加上export

// 表示其他模块/文件可以引入Id这个类
export class Id {...}
TypeScript特性——基础类型 string、number、boolean、void

Id 类中定义了一个私有成员_val,其类型为string。在TypeScript中,string属于基本类型,同属的还有booleannumber数组元组枚举anyvoidnullundefinedneverObject。我们先介绍目前为止项目中用到的基础类型,其余的在后续中碰到时再做详细介绍,大家也可以到官方文档的中查询所有的基础类型。

字符串 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类型没有什么意义,因为只能赋值为nullundefined

其他值对象TelephoneFeeUsage基本上也只用到了上述几个基本的TypeScript特性,代码不在本文贴出,具体实现可到github项目上查看。

2.2 实体

本节只介绍 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函数
}

TypeScript特性——函数的可选参数

在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
TypeScript特性——接口

**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, ...}。后面我们将看到,接口里面的函数也支持这种手法进行匿名实现。

2.3 资源库

在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特性——基础类型 数组

TypeScript中数据的声明与Java中的数据声明类是,都是type[]的形式,定义时稍微不同,TypeScript在定义数组时通过[]将元素括起来,而Java则是使用{}

let list: number[] = [1, 2, 3];
TypeScript特性——接口 匿名实现

因为在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的出现。

2.4 聚合

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函数
}
TypeScript特性——基础类型 null、undefined

在上述代码的hasBuy函数的实现中,我们通过将findById的返回值与null进行比对来判断是否找到指定id的 CloudService 对象。

在TypeScript中,nullundefined也属于基本类型,它们的值只能是nullundefined。默认情况下nullundefined是所有类型的子类型。 就是说你可以把 nullundefined赋值给number类型的变量。但是,当指定了--strictNullChecks标记时,nullundefined只能赋值给void和它们各自。

那么,两者又有什么区别呢?

null表示"没有对象",即该处不应该有值,转为数值时为0;undefined表示"缺少值",就是此处应该有一个值,但是还没有定义,转为数值时为NaN。

null的典型用法为:

  1. 作为函数的参数,表示该函数的参数不是对象。
  2. 作为对象原型链的终点。

undefined的典型用法为:

  1. 变量被声明了,但没有赋值时,就等于undefined。
  2. 调用函数时,应该提供的参数没有提供,该参数等于undefined。
  3. 对象没有赋值的属性,该属性的值为undefined。
  4. 函数没有返回值时,默认返回undefined。

3 总结

本文是《写给Java程序员的TypeScript入门教程》系列的第二篇,主要介绍了云服务结算系统的domain层设计与实现,包括领域建模代码实现。在介绍代码实现的过程中,穿插介绍了一些TypeScript的特性,主要包括类、接口、基础类型这三类。TypeScript很多特性跟Java比较类似,因此作为Java开发者,入门TypeScript相对来说难度并不大。本文只是介绍了TypeScript中一些最最基础的特性,更多的特性需要在进行实际开发工作时通过查阅官方文档获得。

更多深入的内容,请关注后续的篇章。

你可能感兴趣的:(软件开发,前端,typescript)