产研提效 产研提效 03|关联对象,把列表看作一个整体 JayClock 2024-10-09 2024-10-18 列表请求应该放在哪 当用户登陆系统后,有一个最直接的需求:“作为一个用户,我可以获取到所有的应用的任务列表,以此快速查询到我想要的任务数据。” 大概功能入下图所示。 现在,我们基于用户故事更新一下业务模型
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 classDiagram class User { +id: int +name: string +email: string +workspace: Workspace } class Workspace { +id: int } class Task { +id: int } User "1" -- "*" Workspace User "1" -- "*" Task
我们可以看到,用户和任务之间也是是一对多的关系,我们的现实场景下,代码设计大概如下。如果移动端用户有同样的需求,那就写一个差不多的代码过去。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 @Component ({ selector : 'app-tasks' , standalone : true , template : '<div *ngFor="let item of tasks()">{{item}}</div>' , changeDetection : ChangeDetectionStrategy .OnPush , imports : [NgFor ], }) export class TasksComponent implements OnInit { private destoryRef = inject (DestroyRef ); tasks = signal<Task []>([]); getTasks (): Observable <Task []> { return of ([]); } ngOnInit (): void { this .getTasks () .pipe (takeUntilDestroyed (this .destoryRef )) .subscribe ((tasks ) => { this .tasks .set (tasks); }); } filter ():void {} search (): void {} total (): void readAll (): void {} }
在上述代码中,TasksComponent
类随着业务需求的增加逐渐变得臃肿,导致代码难以维护和扩展。最初,该组件仅负责显示任务列表,但随着时间的推移,用户需求不断增加,如搜索、筛选、统计总数、标记已读等功能被逐一添加。这些功能的实现代码被直接嵌入到 TasksComponent
中,使得该类变得越来越庞大,职责不明确,逻辑复杂度急剧上升。
这种“过长类”(God Class)的设计模式违反了单一职责原则(SRP),即一个类应该只有一个引起它变化的原因。随着功能的增加,TasksComponent
不仅负责数据获取和展示,还承担了搜索、筛选、统计等业务逻辑,导致代码耦合度高,难以理解和修改。当团队成员变动时,新成员需要花费大量时间理解复杂的代码逻辑,甚至可能因为害怕破坏现有功能而不敢进行修改,最终导致代码的可维护性急剧下降。
比如像下图中的 footer 由于不同客户的需求叠加,控制了复数的按钮现隐藏,甚至可以累积近 2000 行。在这种场景下进行所谓的拆解和预估工时已经完全没有意义了,只能先满足功能,再由测试手动验证,至于会不会造成什么问题,完全交给测试手动回归。这种场景各位在阅读本文的时候,说不准就在经历。
按照之前的代码设计描述业务的要求,我们可以很自然地构建出以下模型。
1 2 3 4 5 6 7 8 9 10 import { Injectable } from '@angular/core' ;import { Task } from './task' ;class User { private tasks : Task [] = []; public getTasks (): Obervable { } }
这是一个很糟糕的实现,因为我们把获取请求的具体实现细节引入到了领域逻辑中,因而无法保持领域逻辑的的独立性。那么我们能不能给获取任务列表,单独提供一个对象呢?比如:
1 2 3 interface TasksService { load (pageSize : number , pageNum : number ): Observable <Task []> }
其实也不行,Task 应该被 User 聚合。那么为了保证 User 是 “逻辑丰富” 的模型,我们希望通过 User 聚合根获取 Task 列表,而直接对外暴露获取任务列表的对象,相当于把模型中不存在的内容泄漏了出去。
这种两难的局面根源在于,我们希望在模型中使用集合接口,并借助它封装具体技术实现的细节 。
关联对象:体现关联关系 关联对象 。即将对象间的关联关系直接建模出来,再通过接口与抽象的隔离,把具体的实现细节封装到接口的实现中 。这样既可以保证概念上的统一,又能够避免技术实现上的限制。
这样解决了第一个问题,就是用一套逻辑,保证了 pc 端,mobile 端,两边的行为一致性。我们也的确达成了最开始模型层 但是在上述内容中,我们还要防止过长类的出现。很简单,将 user 和 tasks 之间的关系,看作一个整体。抽象至一个单独的类中。至于命名,我们可以直接将 2 个实体名称拼接起来,在构建统一语言时,在根据与业务的沟通,使其变得更加语义化,比如 MyTasks,现在我们先简单拼接为 UserTasks 。
使用关联对象体现聚合关系 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 import { Observable } from 'rxjs' ;import { IBackendWorkspace , WorkSpace } from './workspace' ;import { Task } from './task' ;export interface IBackendUser { uid : number ; lastWsInfo : IBackendWorkspace ; } export class User { id : number ; constructor (public backendUser : IBackendUser ) { this .id = backendUser.uid ; } } export interface IUserTasks { load : () => Observable <Task []>; filter : () => void ; search : () => void ; readAll : () => void ; } export interface IUserService { findByUserId : (uid : number ) => Observable <User >; getCurrentWorkspace : (user : User ) => Observable <WorkSpace >; getUserTasks : (user : User ) => IUserTasks ; }
增添 usertasks 的实现层 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 import { HttpClient } from '@angular/common/http' ;import { inject, Injectable , Injector , runInInjectionContext, } from '@angular/core' ; import { map, Observable } from 'rxjs' ;import { IBackendTask , Task } from 'src/models/task' ;import { IUserTasks , User } from 'src/models/user' ;@Injectable ({ providedIn : 'root' })export class UserTasksService { private injector = inject (Injector ); createUserTasks (user : User ): IUserTasks { return runInInjectionContext (this .injector , () => new UserTasks (user)); } } class UserTasks implements IUserTasks { private httpClient = inject (HttpClient ); constructor (private user : User ) {} load (): Observable <Task []> { return this .httpClient .get <IBackendTask []>(`users/${this .user.id} /tasks` ) .pipe (map ((res ) => res.map ((item ) => new Task (item)))); } filter ( ) {} readAll ( ) {} search ( ) {} }
更新 user 的实现层 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 import { HttpClient } from '@angular/common/http' ;import { inject, Injectable } from '@angular/core' ;import { map, Observable , of , switchMap } from 'rxjs' ;import { IBackendUser , IUserService , IUserTasks , User } from 'src/models/user' ;import { WorkSpace } from 'src/models/workspace' ;import { UserTasksService } from './user-tasks.service' ;@Injectable ({ providedIn : 'root' })export class UserService implements IUserService { private httpClient = inject (HttpClient ); private userTasksService = inject (UserTasksService ); findByUserId (uid : number ): Observable <User > { return this .httpClient .get <IBackendUser >(`users/${uid} ` ) .pipe (map ((res ) => new User (res))); } getCurrentWorkspace (user : User ): Observable <WorkSpace > { return of (new WorkSpace (user.backendUser .lastWsInfo )); } getUserTasks (user : User ): IUserTasks { return this .userTasksService .createUserTasks (user); } }
最终在模型指导下获取数据,就变成了下面的代码。如果用通用语言来描述,那就是“获取某一个用户信息后,根据用户信息,获取当前用户下的所有任务列表”。这样既保证了业务行为的一致性,由保证了代码的可读性。
1 2 3 4 5 6 const userService = inject (UserService );userService.findByUserId (123 ).subscribe ((user ) => { const userTasks = userService.getUserTasks (user); userTasks.load (); userTasks.filter (); });
更好的技术实现隔离 引入关联对象,我们可以更好地隔离领域逻辑和技术实现细节。 还是在当前的例子上来解释。 由于后端进行重构,或者前端通过 BFF 进行了数据的聚合,导致新返回的数据有了较大的 break change。那么,我们只需要提供另一个 IUserTasks
的实现就可以了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 class UserTasks implements IUserTasks { private httpClient = inject (HttpClient ); constructor (private user : User ) {} load (): Observable <Task []> { return this .httpClient .get <IBackendTask []>(...) .pipe (map ((res ) => res.map ((item ) => new Task (item)))); } filter ( ) {} readAll ( ) {} search ( ) {} }
这种改变并不会传递到接口层,对于根据用户获取任务列表的调用依然为
1 2 3 4 5 6 const userService = inject (UserService );userService.findByUserId (123 ).subscribe ((user ) => { const userTasks = userService.getUserTasks (user); userTasks.load (); userTasks.filter (); });
不论后端 api 如何变更,这些变化都完全被关联对象的接口封装隔离了。我们使用自定义关联对象,去描述列表的行为,就和使用 class 描述实体与值对象一样,真正把行为封装到了接口之上。