文章首发于我的博客 https://github.com/mcuking/bl...译者:最近一直在研究前端架构分层,学习了一些 DDD/Clean Architecture 知识,在 medium 看到这篇文章对我启发很大,特地翻译过来分享给大家。后续也会把相关思想集成到我的 web 最佳实践项目中去。
https://github.com/mcuking/mo...
如何创建一个包来管理应用的业务规则、API 调用、localStorage,以及根据需要随时更改前端框架。
单页应用是过去几年中前端开发的主流,而且每天都变得更复杂。这种复杂度带来框架和类库成长的机会,这些框架和类库提供给前端开发者不同的解决方案。 AngularJS, React, Redux, Vue, Vuex, Ember 就是可提供选择的选项。
一个团队会选择任意框架--car2go 对新项目使用 Vue.js--但一旦一个应用变得更加复杂,“重构”这个词汇就变成了任何开发者的梦魇。通常业务逻辑与框架的选择是紧紧绑定的,而从头开始重建整个前端应用会导致团队几周(或几个月)业务逻辑的开发和测试。
这种情况是可以通过将业务逻辑从框架选择中分离来避免的。我会展示一个简单但有效的方式,来实现这个分离,以备随时使用最好的框架从头开始重建你的单页应用,只要你愿意!
注意:我会用 TypeScript 写一个例子,就像我们在 car2go web 团队正在做的一样。当然 ES6, Vanilla JS 等同样可以使用。
A little bit of Clean Architecture
使用 Clean Architecture 概念,这个包会按照 4 个不同的部分组织:
Entities
这部分会包含业务对象模型,数据接口。可以在该部分实现属性校验规则。
Interactors
这部分会包含业务规则。
Services
这部分会包含 API 调用,LocalStorage 处理等。
Exposers
这部分会将 Interactors 的方法暴露给应用。
一个 Clean Architecture(CA)的倡导者会说这根本不是 CA,而且可能是正确的,但是在查看同心层图片时,发现是可以将这个架构模型与其相关联。
- Entities -> Enterprise Business Rules
- Interactors -> Application Business Rules
- Services and Exposers -> Interface Adapters
在 Interactors 中引用 Services 的依赖倒置原则 Dependency Inversion Principle 也存在边界。
这个简单的架构会让写的东西更容易模拟、测试和实现。
Code!!!
这个示例项目可以从下面 clone:
https://github.com/fabriciome...
我们会使用 jsonplaceholder API 创建一个包去获取、创建和保存 post。
Project structure
/showroom # A Vuejs app to test and document package usage
/playground # A simple usage example in NodeJS
/src
/common
/entities
/exposers
/interactors
/services
__mocks__
这个源文件夹是按照一种可以看到每个层的方式来组织,也可以按照功能来组织。
Common folder
这个文件夹包含可以用在不同层的可共享的模块。例如:HttpClient 类--创建一个 axios 的实例然后抽象一些相关方法。
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
export interface IHttpClient {
get: <T>(url: string, config?: AxiosRequestConfig) => Promise<T>;
post: <T>(url: string, data?: any, config?: AxiosRequestConfig) => Promise<T>;
patch: <T>(
url: string,
data?: any,
config?: AxiosRequestConfig
) => Promise<T>;
}
class HttpClient implements IHttpClient {
private _http: AxiosInstance;
constructor() {
this._http = axios.create({
baseURL: 'https://jsonplaceholder.typicode.com',
headers: {
'Content-Type': 'application/json'
}
});
}
public async get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response: AxiosResponse = await this._http.get(url, config);
return response.data;
}
public async post<T>(
url: string,
data?: any,
config?: AxiosRequestConfig
): Promise<T> {
const response: AxiosResponse = await this._http.post(url, data, config);
return response.data;
}
public async patch<T>(
url: string,
data?: any,
config?: AxiosRequestConfig
): Promise<T> {
const response: AxiosResponse = await this._http.patch(url, data, config);
return response.data;
}
}
export const httpClient: IHttpClient = new HttpClient();
Entities
这部分,我们会创建业务对象的接口和类。如果这个对象需要拥有一些规则,最好在这里实现(不是强制的)。但是也可以只是仅仅将数据接口导出,然后在 Interactors 实现校验。
为了说明这个,现在创建下 Post 的业务对象的数据接口和类。
JSONPlaceholder Post 数据对象有 4 个属性:id, userId, title and body。我们会校验 title 和 body,例如:
- title 不能为空,且不应该超过 256 个字符;
- body 不能为空且不能少于 10 个字符;
同时,我们希望分开校验属性(之前的校验),提供额外的校验,以及向对象注入数据。据此我们能提出一些特性来测试。
// Post business object
- copies an object data into a Post instance
- title is invalid for empty string
- title is invalid using additional validator
- title is invalid for long titles
- title is valid
- title is valid using additional validation
- body is invalid for strings with less than 10 characters
- body is invalid using additional validation
- body is valid
- body is valid using additional validation
- post is invalid without previous validation
- post is valid without previous validation
- post is invalid with previous title validation
- post is invalid with previous title and body validation, title is valid
- post is invalid with previous title and body validation, body is valid
- post is valid with previous title validation
- post is valid with previous body validation
- post is valid with previous title and body validation
代码如下:
import { Post, IPost } from './Post';
describe('Test Post entity', () => {
/* tslint:disable-next-line:max-line-length */
const bigString =
'est rerum tempore vitae sequi sint nihil reprehenderit dolor beatae ea dolores neque fugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis qui aperiam non debitis possimus qui neque nisi nulla est rerum tempore vitae sequi sint nihil reprehenderit dolor beatae ea dolores neque fugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis qui aperiam non debitis possimus qui neque nisi nulla';
let post: IPost;
beforeEach(() => {
post = new Post();
});
it('should copy an object data into a Post instance', () => {
const data = {
id: 1,
userId: 3,
title: 'Copy',
body: 'Copied'
};
post.copyData(data);
expect(post.id).toBe(1);
expect(post.userId).toBe(3);
expect(post.title).toBe('Copy');
expect(post.body).toBe('Copied');
});
it('should return title is invalid for empty string', () => {
expect(post.isValidTitle()).toBeFalsy();
});
it('should return title is invalid using additional validator', () => {
post.title = 'New';
expect(
post.isValidTitle((title: string): boolean => {
return title.length > 3;
})
).toBeFalsy();
});
it('should return title is invalid for long titles', () => {
post.title = bigString;
expect(post.isValidTitle()).toBeFalsy();
});
it('should return title is valid', () => {
post.title = 'New post';
expect(post.isValidTitle()).toBeTruthy();
});
it('should return title is valid using additional validation', () => {
post.title = 'Lorem ipsum';
expect(
post.isValidTitle((title: string) => {
return title.indexOf('dolor') < 0;
})
).toBeTruthy();
});
it('should return body is invalid for strings with less than 10 characters', () => {
post.body = 'Lorem ip';
expect(post.isValidBody()).toBeFalsy();
});
it('should return body is invalid using additional validation', () => {
post.body = 'Lorem ipsum dolor sit amet';
expect(
post.isValidBody((body: string): boolean => {
return body.length > 30;
})
).toBeFalsy();
});
it('should return body is valid', () => {
post.body = 'Lorem ipsum dolor sit amet';
expect(post.isValidBody()).toBeTruthy();
});
it('should return body is valid using additional validation', () => {
post.body = 'Lorem ipsum sit amet';
expect(
post.isValidBody((body: string): boolean => {
return body.indexOf('dolor') < 0;
})
).toBeTruthy();
});
it('should return post is invalid without previous validation', () => {
expect(post.isValid()).toBeFalsy();
});
it('should return post is valid without previous validation', () => {
post.title = 'Lorem ipsum dolor sit amet';
post.body = bigString;
expect(post.isValid()).toBeTruthy();
});
it('should return post is invalid with previous title validation', () => {
post.title = 'Lorem ipsum dolor';
post.body = bigString;
expect(
post.isValidTitle((title: string): boolean => {
return title.indexOf('dolor') < 0;
})
).toBeFalsy();
expect(post.isValid()).toBeFalsy();
});
it('should return post is invalid with previous body validation', () => {
post.title = 'Lorem ipsum dolor';
post.body = 'Invalid body';
expect(
post.isValidBody((body: string): boolean => {
return body.length > 20;
})
).toBeFalsy();
expect(post.isValid()).toBeFalsy();
});
it('should return post is invalid with previous title and body validation, title is valid', () => {
post.title = 'Lorem ipsum dolor';
post.body = bigString;
expect(post.isValidTitle()).toBeTruthy();
expect(
post.isValidBody((body: string): boolean => {
return body.length < 300;
})
).toBeFalsy();
expect(post.isValid()).toBeFalsy();
});
it('should return post is invalid with previous title and body validation, body is valid', () => {
post.title = 'Lorem ipsum dolor';
post.body = bigString;
expect(
post.isValidTitle((title: string): boolean => {
return title.indexOf('dolor') < 0;
})
).toBeFalsy();
expect(post.isValidBody()).toBeTruthy();
expect(post.isValid()).toBeFalsy();
});
it('should return post is valid with previous title validation', () => {
post.title = 'Lorem ipsum dolor';
post.body = bigString;
expect(post.isValidTitle()).toBeTruthy();
expect(post.isValid()).toBeTruthy();
});
it('should return post is valid with previous body validation', () => {
post.title = 'Lorem ipsum dolor';
post.body = bigString;
expect(post.isValidBody()).toBeTruthy();
expect(post.isValid()).toBeTruthy();
});
it('should return post is valid with previous title and body validation', () => {
post.title = 'Lorem ipsum';
post.body = bigString;
expect(
post.isValidTitle((title: string): boolean => {
return title.indexOf('dolor') < 0;
})
).toBeTruthy();
expect(post.isValidBody()).toBeTruthy();
expect(post.isValid()).toBeTruthy();
});
});
现在让我们开始实现 Post 的接口和类吧。
最棘手的时就是当检测 post 是否有效时,需要检测 post 属性之前是否校验过。如果之前有任何类型的校验,则不使用内部校验。
_validTitle
和 _validBody
属性应该被初始化为 undefined,当使用之前的校验方法时,会获得一个布尔值。
这样就能在 presentation 层使用属性实时校验,和使用一些很酷的第三方库进行额外的校验--在我们的实例应用(showroom),使用 VeeValidate。
export interface IPost {
userId: number;
id: number;
title: string;
body: string;
copyData?: (data: any) => void;
isValidTitle?: (additionalValidator?: (value: string) => boolean) => boolean;
isValidBody?: (additionalValidator?: (value: string) => boolean) => boolean;
isValid?: () => boolean;
}
export class Post implements IPost {
public userId: number = 0;
public id: number = 0;
public title: string = '';
public body: string = '';
/**
* Private properties to store validation states
* when the application validates fields separetely
* and/or use additional validations
*/
private _validTitle: boolean | undefined;
private _validBody: boolean | undefined;
/**
* Returns if title property is valid based on the internal validator
* and an optional extra validator
* @memberof Post
* @param validator Additional validation function
* @returns boolean
*/
public isValidTitle(validator?: (value: string) => boolean): boolean {
this._validTitle =
this._validateTitle() && (!validator ? true : validator(this.title));
return this._validTitle;
}
/**
* Returns if body property is valid based on the internal validator
* and an optional extra validator
* @memberof Post
* @param validator Additional validation function
* @returns boolean
*/
public isValidBody(validator?: (value: string) => boolean): boolean {
this._validBody =
this._validateBody() && (!validator ? true : validator(this.body));
return this._validBody;
}
/**
* Returns if the post object is valid
* It should not use internal (private) validation methods
* if previous property validation methods were used
* @memberof Post
* @returns boolean
*/
public isValid(): boolean {
if (
(this._validTitle && this._validBody) ||
(this._validTitle &&
this._validBody === undefined &&
this._validateBody()) ||
(this._validTitle === undefined &&
this._validateTitle() &&
this._validBody) ||
(this._validTitle === undefined &&
this._validBody === undefined &&
this._validateTitle() &&
this._validateBody())
) {
return true;
}
return false;
}
/**
* Copy propriesties from an object to
* instance properties
* @memberof Post
* @param data object
*/
public copyData(data: any): void {
const { id, userId, title, body } = data;
this.id = id;
this.userId = userId;
this.title = title;
this.body = body;
}
/**
* Validates title property
* It should be not empty and should not have more than 256 characters
* @memberof Post
* @returns boolean
*/
private _validateTitle(): boolean {
return this.title.trim() !== '' && this.title.trim().length < 256;
}
/**
* Validates body property
* It should not be empty and should not have less than 10 characters
* @memberof Post
* @returns boolean
*/
private _validateBody(): boolean {
return this.body.trim() !== '' && this.body.trim().length > 10;
}
}
Services
Services 是用来通过 API 加载/发送数据、localStorage 操作、socket 连接的类。PostService 类是相当简单的。
import { httpClient } from '../common/HttpClient';
import { IPost } from '../entities/Post';
export interface IPostService {
getPosts: () => Promise<IPost[]>;
createPost: (data: IPost) => Promise<IPost>;
savePost: (data: IPost) => Promise<IPost>;
}
export class PostService implements IPostService {
public async getPosts(): Promise<IPost[]> {
const response = await httpClient.get<IPost[]>('/posts');
return response;
}
public async createPost(data: IPost): Promise<IPost> {
const { title, body } = data;
const response = await httpClient.post<IPost>('/posts', { title, body });
return response;
}
public async savePost(data: IPost): Promise<IPost> {
const { id, title, body } = data;
const response = await httpClient.patch<IPost>(`/posts/${id}`, {
title,
body
});
return response;
}
}
PostService 的 mock-up 也很简单,点这里。
/* tslint:disable:no-unused */
import { IPost } from '../../entities/Post';
export class PostService {
public async getPosts(): Promise<IPost[]> {
return [
{
userId: 1,
id: 1,
title: 'Lorem ipsum',
body: 'Dolor sit amet'
},
{
userId: 1,
id: 2,
title: 'Lorem ipsum dolor',
body: 'Dolor sit amet'
}
];
}
public async createPost(data: IPost): Promise<IPost> {
return {
...data,
id: 3,
userId: 1
};
}
public async savePost(data: IPost): Promise<IPost> {
if (data.id !== 3) {
throw new Error();
}
return {
...data,
id: 3,
userId: 1
};
}
}
Interactors
Interactors 是处理业务逻辑的类。它负责验证是否满足特定用户要求的所有条件 - 基本上是由 Interactors 实现业务用例。
在这个包中,Interactor 是一个单例,它使我们有可能存储一些状态并避免不必要的 HTTP 调用,提供一种重置应用程序状态属性的方法(例如:在失去修改记录时恢复 post 数据),决定什么时候应该加载新的数据(例如:一个基于 NodeJS 应用程序的 socket 连接,以便实时更新关键内容)。
一旦只有 interactors 方法被暴露给 presentation 层,所有业务对象的创建将由它们处理。
我们又能提出一些特性用来测试。
// PostInteractor class
- returns a new post object
- gets a list of posts
- returns the existing posts list (stored state)
- resets the instance and throws an error while fetching posts
- creates a new post
- throws there is no post data
- throws post data is invalid when creating post
- throws a service error when creating a post
- saves a new post
- throws a service error when saving a post
代码如下:
import { IPost, Post } from '../entities/Post';
import PostInteractor, { IPostInteractor } from './PostInteractor';
import { PostService } from '../services/PostService';
jest.mock('../services/PostService');
describe('PostInteractor', () => {
let interactor: IPostInteractor = PostInteractor.getInstance();
const getPosts = PostService.prototype.getPosts;
const createPost = PostService.prototype.createPost;
beforeEach(() => {
PostService.prototype.getPosts = getPosts;
PostService.prototype.createPost = createPost;
});
it('should return a new post object', () => {
const post = interactor.initPost();
expect(post.title).toBe('');
expect(post.isValidTitle()).toBeFalsy();
post.title = 'Valid title';
expect(post.isValidTitle()).toBeTruthy();
});
it('should get a list of posts', async () => {
PostService.prototype.getPosts = jest.fn().mockImplementationOnce(() => {
return getPosts();
});
const posts = await interactor.getPosts();
const spy = jest.spyOn(PostService.prototype, 'getPosts');
expect(spy).toHaveBeenCalled();
expect(posts.length).toBe(2);
expect(posts[0].title).toContain('Lorem ipsum');
spy.mockClear();
});
it('should return the existing posts list', async () => {
PostService.prototype.getPosts = jest.fn().mockImplementationOnce(() => {
throw new Error();
});
const posts = await interactor.getPosts();
const spy = jest.spyOn(PostService.prototype, 'getPosts');
expect(spy).not.toHaveBeenCalled();
expect(posts.length).toBe(2);
expect(posts[0].title).toContain('Lorem ipsum');
spy.mockClear();
});
it('should reset the instance and throw an error while fetching posts', async () => {
PostInteractor.resetInstance();
interactor = PostInteractor.getInstance();
PostService.prototype.getPosts = jest.fn().mockImplementationOnce(() => {
throw new Error();
});
let error;
try {
await interactor.getPosts();
} catch (err) {
error = err;
}
expect(error.message).toBe('Error fetching posts');
});
it('should create a new post', async () => {
const data: IPost = new Post();
data.title = 'Lorem ipsum dolor';
data.body = 'Dolor sit amet';
const post = await interactor.createPost(data);
expect(post).toBeDefined();
expect(post.id).toBe(3);
expect(post.title).toEqual(data.title);
expect(post.title).toEqual(data.title);
});
it('should throw there is no post data', async () => {
let post;
let error;
try {
post = await interactor.createPost(undefined);
} catch (err) {
error = err;
}
expect(error.message).toBe('No post data provided');
});
it('should throw post data is invalid when creating post', async () => {
const data: IPost = new Post();
data.body = 'Dolor sit amet';
let post;
let error;
try {
post = await interactor.createPost(data);
} catch (err) {
error = err;
}
expect(error.message).toBe('The post data is invalid');
});
it('should throw a service error when creating a post', async () => {
PostService.prototype.createPost = jest.fn().mockImplementationOnce(() => {
throw new Error();
});
let error;
const data: IPost = new Post();
data.title = 'Lorem ipsum dolor';
data.body = 'Dolor sit amet';
try {
await interactor.createPost(data);
} catch (err) {
error = err;
}
expect(error).toBeDefined();
expect(error.message).toBe('Server error when trying to create the post');
});
it('should save a new post', async () => {
const data: IPost = new Post();
data.userId = 1;
data.id = 3;
data.title = 'Lorem ipsum dolor edited';
data.body = 'Dolor sit amet';
const post = await interactor.savePost(data);
expect(post).toBeDefined();
expect(post.id).toBe(3);
expect(post.title).toEqual(data.title);
expect(post.title).toEqual(data.title);
});
it('should throw a service error when saving a post', async () => {
const data: IPost = new Post();
data.userId = 1;
data.id = 2;
data.title = 'Lorem ipsum dolor edited';
data.body = 'Dolor sit amet';
let error;
try {
await interactor.savePost(data);
} catch (err) {
error = err;
}
expect(error).toBeDefined();
expect(error.message).toBe('Server error when trying to save the post');
});
});
现在让我们开始实现 PostInteractor 接口和类吧。
import { IPost, Post } from '../entities/Post';
import { IPostService, PostService } from '../services/PostService';
export interface IPostInteractor {
initPost: () => IPost;
getPosts: () => Promise<IPost[]>;
createPost: (data: IPost) => Promise<IPost>;
savePost: (data: IPost) => Promise<IPost>;
}
export default class PostInteractor implements IPostInteractor {
private static _instance: IPostInteractor = new PostInteractor(
new PostService()
);
public static getInstance(): IPostInteractor {
return this._instance;
}
public static resetInstance(): void {
this._instance = new PostInteractor(new PostService());
}
private _posts: IPost[];
private constructor(private _service: IPostService) {}
public initPost(): IPost {
return new Post();
}
public async getPosts(): Promise<IPost[]> {
if (this._posts !== undefined) {
return this._posts;
}
let response;
try {
response = await this._service.getPosts();
} catch (err) {
throw new Error('Error fetching posts');
}
this._posts = response;
return this._posts;
}
public async createPost(data: IPost): Promise<IPost> {
this._checkPostData(data);
let response;
try {
response = await this._service.createPost(data);
} catch (err) {
throw new Error('Server error when trying to create the post');
}
return response;
}
public async savePost(data: IPost): Promise<IPost> {
this._checkPostData(data);
let response;
try {
response = await this._service.savePost(data);
} catch (err) {
throw new Error('Server error when trying to save the post');
}
return response;
}
private _checkPostData(data: IPost): void {
if (!data) {
throw new Error('No post data provided');
}
if (data.isValid && !data.isValid()) {
throw new Error('The post data is invalid');
}
}
}
Exposers
现在我们已经准备将我们的包暴露给应用。使用 exposers 的原因是我们发布的 API 独立于实现而被使用,根据环境或应用导出一组方法以及使用不同的名字。
通常 exposers 只是简单地导出这些方法。所以我们不需要添加逻辑。
import PostInteractor, { IPostInteractor } from '../interactors/PostInteractor';
import { IPost } from '../entities/Post';
export interface IPostExposer {
initPost: () => IPost;
posts: Promise<IPost[]>;
createPost: (data: IPost) => Promise<IPost>;
savePost: (data: IPost) => Promise<IPost>;
}
class PostExposer implements IPostExposer {
constructor(private _interactor: IPostInteractor) {}
public initPost(): IPost {
return this._interactor.initPost();
}
public get posts(): Promise<IPost[]> {
return this._interactor.getPosts();
}
public createPost(data: IPost): Promise<IPost> {
return this._interactor.createPost(data);
}
public savePost(data: IPost): Promise<IPost> {
return this._interactor.savePost(data);
}
}
/* tslint:disable:no-unused */
export const postExposer: IPostExposer = new PostExposer(
PostInteractor.getInstance()
);
Exporting the library
export { IPost } from './entities/Post';
export * from './exposers/PostExposer';
Using the library
对于 showroom 项目,我们直接 link 这个包到项目里。但是他可以发布到 npm,私有仓库,通过 GitHub, GitLab 安装。这是一个简单的 npm 包,可以像任何其他包一样工作。
可以到文件夹 /showroom
运行 showroom。
然后,在运行 npm link ../
之前运行 npm install
以保证软件包将正确安装,并且不会被 npm 删除。
npm link
命令在开发库时非常有用,一旦在包构建发生更改时它将自动更新依赖的 node_modules 文件夹。
showroom 实时 demo 点这里。
一个简单的 NodeJS(我们也能在后端采用这种方式)使用示例可以在 playgound
文件夹找到。为了验证它,只需要去这个文件夹下,运行 npm link ../
,然后运行 node simple-usage.js
,然后再 console 中查看结果。
const postExposer = require('business-rules-package').postExposer;
let posts;
let post;
(async () => {
try {
posts = await postExposer.posts;
console.log(`${posts.length} posts where loaded`);
} catch (err) {
console.log(err.message);
}
post = postExposer.initPost();
post.title = 'Title example';
post.body = 'Should have more than 10 characters';
try {
post = await postExposer.createPost(post);
console.log(`Created post with id ${post.id}`);
} catch (err) {
console.log(err.message);
}
// set a random post to edit
post = postExposer.initPost();
post.copyData(posts[47]);
post.title += ' edited';
try {
post = await postExposer.savePost(post);
console.log(`New title is '${post.title}'`);
} catch (err) {
console.log(err.message);
}
})();
如果你有任何疑惑、建议或者不同观点,请留言让我们一起讨论前端架构。对于同一个问题,看到不同的观点真是太棒了。这也一直是学习新事物的地方。感谢阅读! :)
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。