乐趣区

关于前端:前端架构让重构不那么痛苦译

文章首发于我的博客 https://github.com/mcuking/bl…

译者:最近始终在钻研前端架构分层,学习了一些 DDD/Clean Architecture 常识,在 medium 看到这篇文章对我启发很大,顺便翻译过去分享给大家。后续也会把相干思维集成到我的 web 最佳实际我的项目中去。
https://github.com/mcuking/mo…

原文链接 https://medium.com/sharenowte…

如何创立一个包来治理利用的业务规定、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);
  }
})();

如果你有任何纳闷、倡议或者不同观点,请留言让咱们一起探讨前端架构。对于同一个问题,看到不同的观点真是太棒了。这也始终是学习新事物的中央。感激浏览! :)

退出移动版