集中精力,构造标准

大家好,我是在前端市场摸爬滚打近 4 年的前端开发。这段时间,由于企业盈利能力下降,我也尝试跳出开发角色,观察产研的成本消耗,大概总结为以下几点:

  1. “死文档式”记录:即所有的业务知识记录在类似于语雀的外部文档中,但是并没有持续地进行更新(或者是业务发生变化后,就新开一个文件)。随着人员的流动,我们很难追溯已有的逻辑。最后的沟通就变成了“按照线上来”,“线上咋样,不知道啊”。所谓的拆解,工时预估,也不再具有参考意义。
  2. “埋雷式”开发
  3. :做到一半了,才发现原来这段代码被另一个业务引用了。那怎么办呢,if-else 大法,或者直接 cv 大法。反正“代码和人能跑一个就行”
  4. 只磨刀不砍柴:质量监测工具(SonarQube,test covery,sentry) 的根本作用在于减少人工发现代码问题的成本,但是发现问题后呢?”测试覆盖率不足是否有制定要求“,“代码重构前是否有覆盖安全网”。问题越积越多,解决越来越少

这些问题,我将其称为四驱车式开发,即只有横冲直撞碰到问题后,才去扭转方向,原有的问题依旧存在,随着累积问题越来越多,四驱车只能在一个名为“大泥球”的遗留系统内胡乱碰撞。这些国内 90% 以上的公司都存在,在过去 20 年的互联网高速发展时期,做的再烂,总能吃到饭。但是随着数字化浪潮以及行业竞争的加剧,我们要求系统具有更好的用户体验、更高的质量、更快地满足变化的需求。这种不断在累积问题的开发方式,将极大地拖累企业在市场的竞争力。而这篇文章,就是为了尝试从四驱车式开发,转变为轨道车式开发,让开发变得有序,有迹可循。

业务建模 — 难以同频的“方法论”

在去年第一次阅读《用户故事与敏捷开发》后,借助公司内一个重构任务,初次尝试了“事件风暴”以及“根据用户故事点安排任务”。从最终的结果上来说,只能用四个字来形容 ——— 一塌糊涂。其实原因很简单,业务建模中包含了太多了概念,什么“实体”,“聚合”,“上下文”,“值对象”,“事件风暴”。是一个只能不断迭代试错的方案。大家知道这东西好,但是怎么学习,学好以后怎么扩大到整体团队,怎么融入开发流程。都需要耗费大量精力去解决。这里的精力主要集中在 2 点,

  1. 我根据互联网业务建模的理论,基于企业业务进行模仿学习,总结实践出独有的“方法论”。
  2. 将我的“方法论”同屏到整个团队

第一点没什么好的解决方案,抓住一切机会去实践,踩坑即可。在大家都不会的前提下,只有先去做,然后交给时间迭代就好。

第二点,传统的方式是,不断地进行大量分享,在分享过后,由他人实践反馈。但是这里的实践 -> 反馈 -> 再实践的过程及其漫长,我们需要探索如何借助人工智能,引入 ai 工具作为团队学习和发展的工具,来帮助团队不断提升认知与技能。

要想引入工具,得先设计标准

在整个软件开发生命周期中,主要作用在于把 AI 作为快速对齐团队认知的工具,以此降低方法论的实践与落地成本。现在软件开发周期的各个阶段中都已经有较为优秀的的工具了,但是工具的引入往往携带着成本,要想争取到引入工具的资源,那么最直接的方式就是以企业业务为基准,设计一套完整的工程化流程,以获取管理层的信任。

互联网上,关于如何进行业务建模,以及构造符合业务模型的代码,基本都是以 java 为例。其实从前端的视角上来说,区别无非是后端从数据库中读取数据,前端从接口中读取数据。以及后端是同步的,前端是异步的。而我平时使用的是 angular 框架,本身也和 java 设计比较类似,只要临时补一下 java 相关的知识,还是能够很快实现一个基础示例的。

关联对象,把列表看作独立的个体

可以从头开始建模 + 设计的机会可遇不可求,所以我选择自己所在行业中常见的低代码平台,将其看作一个遗留系统,来进行建模。以下是截图和已知的相关需求

  • 用户故事 1: 作为一个用户,我可以获取到所有的应用的任务列表,以此快速查询到我想要的任务数据。

这里先跳过建模操作,直接看简化过后的模型。

我们可以看到,用户和任务之间是一对多的关系,如果没有业务建模的情况下,我们一般像下面代码那样实现。如果移动端用户有同样的需求,那就写一个差不多的代码过去。

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 {}
}

业务需求简单的场景下,并不会造成什么问题。但是今天用户想要一个搜索功能,明天想要在页面上看到总数据量呢,再后天我想要进行筛选呢,最终着一个文件会变得越来越长,class TasksComponent 就会变成一个过长类,过长类中携带了过多的业务信息,随着人员的变动导致代码几乎无法阅读,而无法阅读的代码就是无法修改的代码。

比如像下图中的 footer 由于不同客户的需求叠加,甚至可以累积近 2000 行。在这种场景下进行所谓的拆解和预估工时已经完全没有意义了,只能先满足功能,再由测试手动验证,至于会不会造成什么问题,完全交给测试手动回归。这种场景各位在阅读本文的时候,说不准就在经历。

如果是在模型的指导下去实现呢,最简单的方式就是如下:

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
import { Injectable } from '@angular/core';
import { Task } from './task';

class User {
private tasks: Task[] = [];

public getTasks() {}

// 筛选
filter(): void {}

// 搜索
search(): void {}

// 总数
total(): void;

// 已读全部
readAll(): void {}
}


@Injectable()
class UserService {
findByUserId(userId: string) {}
}

这样解决了一个问题,就是用一套逻辑,保证了 pc 端,mobile 端,两边的行为一致性。但是 user 对象依旧会在业务愈加复杂的场景下,,很简单,将 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
31
import { Injectable } from '@angular/core';
import { Task } from './task';

class User {
constructor(private userid: string) {}
}

class UserTasks {
private tasks: Task[] = [];

public getTasks() {}

// 筛选
filter(): void {}

// 搜索
search(): void {}

// 总数
total(): void;

// 已读全部
readAll(): void {}
}

@Injectable()
class UserService {
findByUserId(userId: string) {}

getUserTasks(user: User): Observable<UserTasks> {}
}

最终在模型指导下获取数据,就变成了下面的代码。如果用通用语言来描述,那就是“获取某一个用户信息后,根据用户信息,获取当前用户下的所有任务列表”。这样既保证了业务行为的一致性,由保证了代码的可读性。

未来如果新增一个需求“作为一个用户,我希望批量更新任务数据,以此加快处理数据的效率”。那么只需要在UserTasks 增加一个 batchUpdate 即可。

如果新增需求是“作为一个用户,我希望获取所有的应用,以此快速选择应用发起数据”。那么可以再新增一个叫做 “UserApplications” 的对象。

1
2
3
4
const userService = inject(UserService);
const userTasks$ = userService
.findByUserId('userid')
.subscribe((user) => userService.getUserTasks(user));

角色扮演,解耦接口的利器

在这一整个列表下,列表内容被称为 tag,但是 tag 下有应用(application) 和仪表盘(dashboard) 两种类型。目前现有的 tag 数据接口大概是这样。

1
2
3
4
5
6
7
export interface Tag {
tagId: number;
dashKey: string;
appKey: string;
dashConfig: any;
appConfig: any;
}

对应的数据列表就是这个样子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[
{
tagId: 1,
dashKey: null,
appKey: 'application',
dashConfig: null,
appConfig: {},
},
{
tagId: 2,
dashKey: 'dashKey',
appKey: null,
dashConfig: {},
appConfig: null,
},
];

这种接口大家平时都接触过,可以说是一种非常丑陋且糟糕的实践,一旦新增类型就会导致各种各样的空数据。这样在我们构造模型时,就会导致一个 tag 对象下,包含不同的逻辑,很快又会变成一个过长类。

1
2
3
4
5
6
7
8
9
export class Tag {
// application 相关的
// 发起数据
applyData() {}

// Dashboard 相关的
// 门户全屏
screeFull() {}
}

这个问题如何解决呢,我们可以把 application 和 dashboard 看作 tag 在不同的业务场景下,扮演的不同角色。

1
2
3
4
5
6
7
8
9
10
11
12
13
export class Tag {
asApplication = () => {
return {
applyData: (tag: Tag) => {},
};
};

asDashboard = () => {
return {
screenfull: (tag: Tag) => {},
};
};
}

按照通用语言的描述就是 “tag 作为 application,可以发起数据 (data)”。当然更加极致的揭示意图的方式是,把上下文( context )相关的内容,也体现在模型中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export class Tag {
inApplicationContext = () => {
return {
asApplication: (tag: Tag) => {
return {
applyData: () => {},
};
},
};
};

inDashboardContext = () => {
return {
asDashboard: (tag: Tag) => {
return {
screenfull: () => {},
};
},
};
};
}

按照通用语言的描述就是 “tag 在 application 上下中,作为应用,可以发起数据 (data)”。这样我们的代码,即直接体现了业务模型,研发可以直接根据模型,直接映射到相关逻辑。同时在通用语言的帮助下,我们可以快速结合模型,描述出具体的业务功能,以此加速沟通的效率。

融合遗留系统

业务建模推广首要问题在于,企业并不会提供时间,专注于对模型的梳理。建模这个行为,只能像持续重构一样,需要融合进平日的开发流程中,以小步快跑的方式,不断地去迭代和完善。我们现在既要构建模型,又要同时满足对需求的开发。那么对模型的构建方针就很明确了。那就是优先为新需求进行详细的建模,在实现层直接使用模型来满足业务的开发即可。

现在我们再看一开始的例子

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

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 {}

// 此处省略 1000 行
}

如果现在有一个需求,“作为一个用户,我希望批量更新任务数据,以此加快处理数据的效率”,那么极简化的模型构建就是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

class User {
constructor(private userid: string) {}
}

class UserTasks {
// 批量更新
batchUpdate(): void {}
}

@Injectable()
class UserService {
findByUserId(userId: string): Observable<User> {}

getUserTasks(user: User): Observable<UserTasks> {}
}

const userService = inject(UserService);

const userTasks$ = userService
.findByUserId('userid')
.subscribe((user) => userService.getUserTasks(user));

然后在 UI 层拿到关联对象 UserTasks 实例,将实例的 batchUpdate 方法,绑定到 html 上即可。遗留系统中剩下的逻辑,由于信息的缺失,唯一的办法就是一点点对测试逻辑进行补完,在持续重构的过程中,梳理出符合业务的流程,再将其更新至模型层与应用层中。

天时地利人不和

一切的落地,都要回归于团队。曾经我和公司内同事简单聊过业务模型和测试驱动,接收到的信息一般总结下来一句话“方案很完善,但大家没空去实践”。在有意识把“质量控制”放在核心价值观之前,整个建模流程很难在团队中迭代循环出来。我能做的,就是对我现在实现的功能,更新模型。借助 ngdoc 一类的工具,将其具像化为一个对外使用的 api 文档。寄希望于别人接手我的代码时,可以实际体会一下“模型于软件实现相关联”的美妙之处。

去年在玩某个游戏还没退坑的时候,我很喜欢一个角色的一个来自于《Ulysses》的台词。“尽管被时间消磨,被命运削弱,我们的意志坚强如故,坚持着奋斗、探索、寻求、而不屈服”。在整个业务建模的推广过程中,有人觉得引入了太多概念,有人觉得目前开发做好前端自己的活就好,有人觉得我做这些事纯粹是在折磨自己,最终只会落得一个做不成又坏自己心情的境地。但我相信,这些帮助分析企业业务,定义客户问题的技术,将和数据结构和算法一样,变成一个工程师的基本能力。我要做的,就是实践、实践、不择手段地实践,把自己变成企业敏捷转型和处理遗留系统不可或缺的人才。将所有的经验总结出可阅读的“方法论”。至于别人学不学,我相信市场会推动他们进行变革。

基于 shire 的简单实践

一些灵光一闪的想法,防止忘记

  1. 从可测试性角度,重新理解 angular 依赖注入
  2. 迭代业务模型,构建优质数据,积累数字资产

模型层(废弃,先不删)

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 { type Observable } from 'rxjs';

export class User {}

export interface Subscription {}

export interface MySubscriptions {
/**
* 用户的订阅列表
*/
list$: Observable<Subscription[]>;
/**
* 用户的订阅总花费
*/
totalSubscriptionFee$: Observable<number>;
}

interface Reader {
getMySubscriptions: (user: User) => MySubscriptions;
}

export interface SubscriptionContext {
asReader: () => Reader;
}

export interface UserRepository {
findUserById: () => Observable<User>;
inSubscriptionContext: () => SubscriptionContext;
}

在以上的模型层代码中,在过去我和公司里一些同事都纠结过一个问题。那就是在模型层中,导入 rxjs 这个第三方库是否合理。我们看看老师的原话是怎么说的:“其实我始终推荐不要过分强调领域层的绝对独立性,心里坦然接受领域层并不是无约束的理想化实现,而是受特定技术栈与技术生态环境约束的实现,就没那么多烦恼与纠结了”。rxjs 虽然只是一个 js 的流式处理库,但它却是 angular 框架中不可分割的一部分。在 angular 技术栈及其生态下,把 rxjs 直接当作一种 common 类型导入就是了,在编写测试用例时。也有的同事问我如果未来做“微前端”的时候,换了一个前端框架,如 React 怎么办。我是这么回答的:

  1. 使用 react 无非是把接口请求换成了 axiosaxios 返回的是 Promise 格式,Promiserxjs 返回的 Observable 格式相互转化也就是一行代码的事情。
  2. 你都换框架了,重写成本基本都在实现层和 UI 层上,为了这个不一定会发生的事情花大时间构建可复用模型的意义也不是很大,毕竟模型层在业务知识传递清楚和当今 ai 工具的发展,二次调整的成本是很低的
  3. 当前重点在于把整个团队的认知差异拉齐,先不要考虑过于久远的事情。

应用层(废弃,先不删)

angular 的依赖注入,基本取自于 java。更准确来说,两者的依赖注入,仅仅只是依赖倒置原则在不同框架语言下的实现罢了。这对“关联对象”的实现的推广带来了极低的成本,因为用的现成的框架实现,即使设计模式不扎实,简单模仿就能实现 “模型于软件实现想关联”。

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
import { HttpClient } from '@angular/common/http';
import { inject, Injectable, Injector, runInInjectionContext } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class UserRepositoryService implements UserRepository {
private subscriptionContextService = inject(SubscriptionContextService);
findUserById = () => of(new User());
inSubscriptionContext = () => this.subscriptionContextService;
}

@Injectable({ providedIn: 'root' })
export class SubscriptionContextService implements SubscriptionContext {
private injector = inject(Injector);
asReader = (): Reader => {
return {
getMySubscriptions: (user: User) => {
return runInInjectionContext(this.injector, () => new MySubscriptionsService(user));
},
};
};
}

@Injectable({ providedIn: 'root' })
export class MySubscriptionsService implements MySubscriptions {
private http = inject(HttpClient);

constructor(private user: User) {}

list$ = this.http.get<MySubscriptions[]>('user/1/subscriptions');
totalSubscriptionFee$ = this.http.get<number>('url');
}

以上代码只是为了简单演示,全部写在了一个代码文件中。实际的代码组织大概长这样。