应用层骨架
领域驱动才用自顶而下的写法,所以我们第一步是写应用层。应用层是直接面向用例的,因此你应该对着用例图:
点击查看【processon】
export const 检测登录状态 = () => {};export const 登录 = () => {};export const 退出 = () => {};
如你所见,应用层就应该是用例图的代码翻译,它要完成的体现用例。如果用例比较少,你可以把所有用例放一个文件里,否则可以每条用例建一个文件。
应用的职责是编排领域层和适配器完成业务用例,它还应包括数据的校验,转换等。
适配器层
所谓适配器就是按照依赖倒置原则,以ports+implements实现的面向接口的编程模式:ports声明好接口, 适配器层实现这些接口,你可以简单的理解为面向接口编程:
import { User } from 'auth/models/user'export interface Api {detectLoginStatus(): Promise<boolean>login(username: string, password: string): Promise<User>logout(): Promise<void>}export interface Store {currentUser: UsersetCurrentUser(user: User): void}
ports要写在应用层,这样方便查找。为了完成这些用例,我们需要后端拉接口,要把当前用户存起来,此外因为是纯dom编写,所以还要处理一下dom事关,让它们来消费用例。这些都是牵涉到副作用,或者设计具体的技术方案,也就是说它们是业务逻辑之外的,这些都用适配器实现,和领域层隔离开来。
适配器又分为入方向的适配器(如后端来的api)和出方向的适配器(如往前端的store写入数据),这样划分一下只是为了里让架构在脑中更为清晰(不必建不同的文件夹),通常模式是,我们从入方向的是适配器读数据,通过出方向的适配器写数据。在鉴权域我们用到了api,store,handers这三个适配器。
适配器的种类和数量是由技术选型决定的,并不是固定的一套,两个子域可能有完全不同的适配器。
mock
import { Api } from 'auth/app/port'export default <Api>{detectLoginStatus: async () => false,login: async (username: string, _password: string) => ({ username, type: 'user' }),logout() { }}
可以看到我们用的是mock,适配器模式的好处是双方都只依赖抽象(接口),app层并不关心实现这些接口的是真实api还是mock。
store
import { Store } from 'auth/app/port'import { User } from 'auth/models/user'export default <Store>{currentUser: undefined,setCurrentUser(user: User) {this.currentUser = user}}
store 也和技术无关,mobx,rxjs,reudx等均可, 不同的方案对应着不同复杂程度的项目,这里就简单的使用对象。
handlers
import * as app from '../app'window.addEventListener('load', app.检测登录状态)window.addEventListener('auth', (e: CustomEvent) => {const { type, payload } = e.detailswitch (type) {case 'login':const { username, password } = payload.target.elementsapp.登录(username.value, password.value)breakdefault:break}})
领域层
目前领域层只有一个类型声明,如果有复杂的领域逻辑,会放在这里。
export interface User {username: stringtype: 'user' | 'admin'}
领域逻辑基本是维护在后端的,前端的领域层会比较薄甚至没有,这一点后面会详细说明。
应用层最终代码
import api from 'auth/adts/mock'import { navigate } from 'lib/utils'import store from 'auth/adts/store'export async function 检测登录状态() {const isLogin = await api.detectLoginStatus()if (isLogin) navigate('/')else navigate('/login')}export async function 登录(username: string, password: string) {const user = await api.login(username, password)store.setCurrentUser(user)navigate('/')}export async function 退出() {await api.logout()navigate('/login')}
代码要表达业务
应用层代码尽可能要表达用例的流程步骤,也就是要尽量是用例时序图的表达。因为我们的代码最终是可以给产品和业务专家浏览的。以“检测登录状态”为例,这个用例的步骤应该是“调后端接口检测是否登录,如果未登录就是跳转到登录页,否则就跳到首页”,那么这个步骤序列要表达在代码里。从上面代码可以看到:把代码翻译一下,就可以作为产品的说明文档,这就是前面所说的“代码即产品”。
另外,目前前端都是数据驱动的框架,而用例本身一个行为的序列,为了弥合这个差异,在某些情况下需要做一点转换,把响应式包装为命令式:
// badsetModalVisible(true)// goodconst openModal = () => setModalVisible(true)const closeModal = () => setModalVisible(false)
如此一来,代码就切实的表达了用例的时序。我们当然是追求代码的可读性的,但这种可读性指的是业务层面的可读性。
用例包含交互
你可能疑惑为什么把跳转相关的代码写在应用层,因为过两天产品可能会让改成弹窗登录,也就是说这部分代码是不稳固的。
最主要的原因是这个跳转本身就属于前端用例的一部分,它虽然不是业务用例,但是是前端用例,属于用例的就要在应用层完整体现。通常说来业务状态基本都是在后端维护的,无论业务多么复杂,前端大部分情况下也只是调一下接口。因此,从代码的维护性来说,业务相关的代码通常不会有可读性问题(在重构老项目时,甚至不去看这部分代码,直接看接口的传参和返回)。
而复杂交互却是前端需要关注的。以一个“可拍照输入银行卡号”的input框为例,这个输入框可以直接输入或调用摄像头拍照获取用户银行卡号——前端更关注的是唤起摄像头的一些列操作,还是是怎么把卡号传给后端呢?这就是领域驱动在前端不一样的地方,我们更关注交互,交互逻辑的复杂度也往往高于业务逻辑。
