应用层骨架

领域驱动才用自顶而下的写法,所以我们第一步是写应用层。应用层是直接面向用例的,因此你应该对着用例图:
点击查看【processon】

  1. export const 检测登录状态 = () => {
  2. };
  3. export const 登录 = () => {
  4. };
  5. export const 退出 = () => {
  6. };

如你所见,应用层就应该是用例图的代码翻译,它要完成的体现用例。如果用例比较少,你可以把所有用例放一个文件里,否则可以每条用例建一个文件。

应用的职责是编排领域层和适配器完成业务用例,它还应包括数据的校验,转换等。

适配器层

所谓适配器就是按照依赖倒置原则,以ports+implements实现的面向接口的编程模式:ports声明好接口, 适配器层实现这些接口,你可以简单的理解为面向接口编程:

  1. import { User } from 'auth/models/user'
  2. export interface Api {
  3. detectLoginStatus(): Promise<boolean>
  4. login(username: string, password: string): Promise<User>
  5. logout(): Promise<void>
  6. }
  7. export interface Store {
  8. currentUser: User
  9. setCurrentUser(user: User): void
  10. }

ports要写在应用层,这样方便查找。为了完成这些用例,我们需要后端拉接口,要把当前用户存起来,此外因为是纯dom编写,所以还要处理一下dom事关,让它们来消费用例。这些都是牵涉到副作用,或者设计具体的技术方案,也就是说它们是业务逻辑之外的,这些都用适配器实现,和领域层隔离开来。

适配器又分为入方向的适配器(如后端来的api)和出方向的适配器(如往前端的store写入数据),这样划分一下只是为了里让架构在脑中更为清晰(不必建不同的文件夹),通常模式是,我们从入方向的是适配器读数据,通过出方向的适配器写数据。在鉴权域我们用到了api,store,handers这三个适配器。

适配器的种类和数量是由技术选型决定的,并不是固定的一套,两个子域可能有完全不同的适配器。

mock

  1. import { Api } from 'auth/app/port'
  2. export default <Api>{
  3. detectLoginStatus: async () => false,
  4. login: async (username: string, _password: string) => ({ username, type: 'user' }),
  5. logout() { }
  6. }

可以看到我们用的是mock,适配器模式的好处是双方都只依赖抽象(接口),app层并不关心实现这些接口的是真实api还是mock。

store

  1. import { Store } from 'auth/app/port'
  2. import { User } from 'auth/models/user'
  3. export default <Store>{
  4. currentUser: undefined,
  5. setCurrentUser(user: User) {
  6. this.currentUser = user
  7. }
  8. }

store 也和技术无关,mobx,rxjs,reudx等均可, 不同的方案对应着不同复杂程度的项目,这里就简单的使用对象。

handlers

  1. import * as app from '../app'
  2. window.addEventListener('load', app.检测登录状态)
  3. window.addEventListener('auth', (e: CustomEvent) => {
  4. const { type, payload } = e.detail
  5. switch (type) {
  6. case 'login':
  7. const { username, password } = payload.target.elements
  8. app.登录(username.value, password.value)
  9. break
  10. default:
  11. break
  12. }
  13. })

领域层

目前领域层只有一个类型声明,如果有复杂的领域逻辑,会放在这里。

  1. export interface User {
  2. username: string
  3. type: 'user' | 'admin'
  4. }

领域逻辑基本是维护在后端的,前端的领域层会比较薄甚至没有,这一点后面会详细说明。

应用层最终代码

  1. import api from 'auth/adts/mock'
  2. import { navigate } from 'lib/utils'
  3. import store from 'auth/adts/store'
  4. export async function 检测登录状态() {
  5. const isLogin = await api.detectLoginStatus()
  6. if (isLogin) navigate('/')
  7. else navigate('/login')
  8. }
  9. export async function 登录(username: string, password: string) {
  10. const user = await api.login(username, password)
  11. store.setCurrentUser(user)
  12. navigate('/')
  13. }
  14. export async function 退出() {
  15. await api.logout()
  16. navigate('/login')
  17. }

这里有几个地方需要注意:

代码要表达业务

应用层代码尽可能要表达用例的流程步骤,也就是要尽量是用例时序图的表达。因为我们的代码最终是可以给产品和业务专家浏览的。以“检测登录状态”为例,这个用例的步骤应该是“调后端接口检测是否登录,如果未登录就是跳转到登录页,否则就跳到首页”,那么这个步骤序列要表达在代码里。从上面代码可以看到:把代码翻译一下,就可以作为产品的说明文档,这就是前面所说的“代码即产品”。

另外,目前前端都是数据驱动的框架,而用例本身一个行为的序列,为了弥合这个差异,在某些情况下需要做一点转换,把响应式包装为命令式:

  1. // bad
  2. setModalVisible(true)
  3. // good
  4. const openModal = () => setModalVisible(true)
  5. const closeModal = () => setModalVisible(false)

如此一来,代码就切实的表达了用例的时序。我们当然是追求代码的可读性的,但这种可读性指的是业务层面的可读性

用例包含交互

你可能疑惑为什么把跳转相关的代码写在应用层,因为过两天产品可能会让改成弹窗登录,也就是说这部分代码是不稳固的。

最主要的原因是这个跳转本身就属于前端用例的一部分,它虽然不是业务用例,但是是前端用例,属于用例的就要在应用层完整体现。通常说来业务状态基本都是在后端维护的,无论业务多么复杂,前端大部分情况下也只是调一下接口。因此,从代码的维护性来说,业务相关的代码通常不会有可读性问题(在重构老项目时,甚至不去看这部分代码,直接看接口的传参和返回)。

而复杂交互却是前端需要关注的。以一个“可拍照输入银行卡号”的input框为例,这个输入框可以直接输入或调用摄像头拍照获取用户银行卡号——前端更关注的是唤起摄像头的一些列操作,还是是怎么把卡号传给后端呢?这就是领域驱动在前端不一样的地方,我们更关注交互,交互逻辑的复杂度也往往高于业务逻辑。