前端架构-让重构不那么痛苦(译)

2019-09-21 19:20:53 +08:00
 mcuking

译者:最近一直在研究前端框架,学习了一些 DDD/Clean Architecture 知识,在 medium 看到这篇文章觉得很棒,把它翻译出来分享给大家。后续也会把相关思想集成到我的 web 最佳实践项目中去。 https://github.com/mcuking/mobile-web-best-practice

原文链接 https://medium.com/sharenowtech/front-end-architecture-making-rebuild-from-scratch-not-so-painful-7b2232dc1666

文章首发于我的博客 https://github.com/mcuking/blog/issues/57

如何创建一个包来管理应用的业务规则、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/fabriciomendonca/business-rules-package

我们会使用 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();
  });

  ...
});

现在让我们开始实现 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();
  });

  ...
});

现在让我们开始实现 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);
  }
})();

如果你有任何疑惑、建议或者不同观点,请留言让我们一起讨论前端架构。对于同一个问题,看到不同的观点真是太棒了。这也一直是学习新事物的地方。感谢阅读! :)

2627 次点击
所在节点    前端开发
0 条回复

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/602906

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX