1. 首页
  2. IT资讯

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

如何创建一个包来管理应用的业务规则,API调用,localStorage,以及根据需要随时更改前端框架。

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

这种复杂度带来框架和类库增长的机会,这些框架和类库提供给前端开发者不同的解决方案。 AngularJS,React,Redux,Vue,Vuex,Ember就是可提供选择的选项。

一个团队会选择任意框架-car2go对新项目使用Vue.js-但一旦一个应用变得更加复杂,“插入”这个词汇就变成了任何开发者的梦魇。通常业务逻辑与框架的选择是紧紧绑定的,而从头开始重建整个前端应用会导致团队几周(或几个月)业务逻辑的开发和测试。

这种情况是可以通过将业务逻辑从框架选择中分离来避免的。我会展示一个简单但有效的方式,来实现这个分离,以备随时使用最好的框架从头开始重建你的单页应用,只要你愿意!

注意:我会用TypeScript写一个例子,就像我们在car2go web团队正在做的一样。当然ES6,Vanilla JS等同样可以使用。

一点干净的架构

使用Clean Architecture概念,这个包会遵循4个不同的部分组织:

实体

这部分会包含业务对象模型,数据接口。可以在该部分实现属性校验规则。

互动者

这部分会包含业务规则。

服务

这部分会包含API调用,LocalStorage处理等。

曝光器

这部分串联Interactors的方法暴露给应用。

一个Clean Architecture(CA)的倡导者会说这根本不是CA,而且可能是正确的,但是在查看同心层图片时,发现是可以将这个架构模型具有相关联。

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

  • 实体->企业业务规则
  • 交互器->应用程序业务规则
  • 服务和曝光器->接口适配器

在Interactors中引用服务的依赖倒置原则Dependency Inversion Principle也存在边界。

这个简单的架构将会写的东西更容易模拟,测试和实现。

码!!!

这个示例项目可以从下面clone:

https://github.com/fabriciome …

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

服务

服务是通过API加载/发送数据,本地存储操作,套接字连接的类。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的模型也很简单,点这里

/* 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');
    }
  }
}

曝光器

现在我们已经准备将我们的包暴露给应用。使用暴露器的原因是我们发布的API独立于实现而被使用,根据环境或应用转换成组方法以及使用不同的名字。

通常暴露者只是简单地推出这些方法。所以我们不需要添加逻辑。

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';

使用图书馆

对于他的展厅项目,我们直接链接到这个包到项目里。但是他可以发布到npm,私有仓库,通过GitHub,GitLab安装。这是一个简单的npm包,可以像任何其他包一样工作。

可以到文件夹运行/showroomgallery。

然后,在运行npm link ../之前运行npm install以保证扩展将正确安装,并且不会被npm删除。

npm link 命令在开发库时非常有用,一旦在包合并发生更改时逐步自动更新依赖的node_modules文件夹。

真实的演示点这里

playgound为了验证它,只需要去这个文件夹下,运行npm link ../,然后运行node simple-usage.js,然后再控制台中查看结果。一个简单的NodeJS(我们也能在采用这种方式)上使用示例可以在文件夹中找到。

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://www.cxybcw.com/203566.html

联系我们

13687733322

在线咨询:点击这里给我发消息

邮件:1877088071@qq.com

工作时间:周一至周五,9:30-18:30,节假日休息

QR code