譯者:最近一直在研究前端架構分層,學習了一些 DDD/Clean Architecture 知識,在 medium 看到這篇文章對我啓發很大,特意翻譯過來分享給你們。後續也會把相關思想集成到個人 web 最佳實踐項目中去。 github.com/mcuking/mob…html
原文連接 medium.com/sharenowtec…前端
文章首發於個人博客 github.com/mcuking/blo…node
如何建立一個包來管理應用的業務規則、API 調用、localStorage,以及根據須要隨時更改前端框架。ios
單頁應用是過去幾年中前端開發的主流,並且天天都變得更復雜。這種複雜度帶來框架和類庫成長的機會,這些框架和類庫提供給前端開發者不一樣的解決方案。 AngularJS, React, Redux, Vue, Vuex, Ember 就是可提供選擇的選項。git
一個團隊會選擇任意框架--car2go 對新項目使用 Vue.js--但一旦一個應用變得更加複雜,「重構」這個詞彙就變成了任何開發者的夢魘。一般業務邏輯與框架的選擇是牢牢綁定的,而從頭開始重建整個前端應用會致使團隊幾周(或幾個月)業務邏輯的開發和測試。github
這種狀況是能夠經過將業務邏輯從框架選擇中分離來避免的。我會展現一個簡單但有效的方式,來實現這個分離,以備隨時使用最好的框架從頭開始重建你的單頁應用,只要你願意!web
注意:我會用 TypeScript 寫一個例子,就像咱們在 car2go web 團隊正在作的同樣。固然 ES6, Vanilla JS 等一樣可使用。typescript
使用 Clean Architecture 概念,這個包會按照 4 個不一樣的部分組織:npm
這部分會包含業務對象模型,數據接口。能夠在該部分實現屬性校驗規則。json
這部分會包含業務規則。
這部分會包含 API 調用,LocalStorage 處理等。
這部分會將 Interactors 的方法暴露給應用。
一個 Clean Architecture(CA)的倡導者會說這根本不是 CA,並且多是正確的,可是在查看同心層圖片時,發現是能夠將這個架構模型與其相關聯。
在 Interactors 中引用 Services 的依賴倒置原則 Dependency Inversion Principle 也存在邊界。
這個簡單的架構會讓寫的東西更容易模擬、測試和實現。
這個示例項目能夠從下面 clone:
咱們會使用 jsonplaceholder API 建立一個包去獲取、建立和保存 post。
/showroom # A Vuejs app to test and document package usage
/playground # A simple usage example in NodeJS
/src
/common
/entities
/exposers
/interactors
/services
__mocks__
複製代碼
這個源文件夾是按照一種能夠看到每一個層的方式來組織,也能夠按照功能來組織。
這個文件夾包含能夠用在不一樣層的可共享的模塊。例如: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();
複製代碼
這部分,咱們會建立業務對象的接口和類。若是這個對象須要擁有一些規則,最好在這裏實現(不是強制的)。可是也能夠只是僅僅將數據接口導出,而後在 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 是用來經過 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 實現業務用例。
在這個包中,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 的緣由是咱們發佈的 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()
);
複製代碼
export { IPost } from './entities/Post';
export * from './exposers/PostExposer';
複製代碼
對於 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); } })(); 複製代碼
若是你有任何疑惑、建議或者不一樣觀點,請留言讓咱們一塊兒討論前端架構。對於同一個問題,看到不一樣的觀點真是太棒了。這也一直是學習新事物的地方。感謝閱讀! :)