- PVSM.RU - https://www.pvsm.ru -

Работа с данными в Angular

Всем привет, меня зовут Сергей и я web разработчик. Да простит меня Дмитрий Карловский [1] за заимствованное вступление, но именно его публикации [2] вдохновили меня написание этой статьи.

Сегодня хотелось бы поговорить о работе с данными в Angular [3] приложениях в целом и о моделях предметной области в частности.

Предположим, что у нас есть некий список пользователей, который мы получаем с сервера в виде

[
  {
    "id": 1,
    "first_name": "James", 
    "last_name": "Hetfield",
    "position": "Web developer"
  },
  {
    "id": 2,
    "first_name": "Elvis", 
    "last_name": "",
    "position": "Project manager"
  },
  {
    "id": 3,
    "first_name": "Steve", 
    "last_name": "Vai",
    "position": "QA engineer"
  }
]

а отобразить его нужно как на картинке

List of users

Выглядит несложно — давайте попробуем. Разумеется для получения этого списка у нас будет сервис UserService примерно следующего вида. Обратите внимание, что ссылка на аватарку пользователя не приходит сразу в ответе, а формируется на основе id пользователя.

// UserService

import {Injectable} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {Observable} from 'rxjs';

import {UserServerResponse} from './user-server-response.interface';

@Injectable()
export class UserService {

  constructor(private http: HttpClient) { }

  getUsers(): Observable<UserServerResponse[]> {
    return this.http.get<UserServerResponse[]>('/users');
  }

  getUserAvatar(userId: number): string {
    return `/users/${userId}/avatar`;
  }
}

За отображения списка пользователей будет отвечать компонент UserListComponent.

// UserListComponent

import {Component} from '@angular/core';
import {UserService} from '../services/user.service';

@Component({
  selector: 'app-user-list',
  template: `
    <div *ngFor="let user of users | async">
      <img [src]="userService.getUserAvatar(user.id)">
      <p><b>{{user.first_name}} {{user.last_name}}</b>, {{user.position}}</p>
    </div>
  `
})
export class UserListComponent {

  users = this.userService.getUsers();

  constructor(public userService: UserService) { }
}

И вот тут у нас уже наметилась определенная проблема. Обратите внимание на ответ сервера. Поле last_name может быть пустым и если мы оставим компонент в таком виде, то будем получать нежелательные пробелы перед запятой. Какие есть варианты решения?

  1. Можно немного поправить шаблон отображения

    <p>
     <b>{{[user.first_name, user.last_name].filter(el => !!el).join(' ')}}</b>, 
     {{user.position}}
    </p>

    Но таким образом мы перегружаем шаблон логикой, и он становится плохочитаемым даже для такой простой задачи. А ведь приложению еще расти и расти...

  2. Вынести код из шаблона в класс компоненты, добавив метод типа

    getUserFullName(user: UserServerResponse): string {
     return [user.first_name, user.last_name].filter(el => !!el).join(' ');
    }

    Уже получше, но скорее всего полное имя пользователя будет отображаться не в одном месте приложения, и нам придется дублировать этот код. Можно вынести этот метод из компоненты в сервис. Таким образом мы избавимся от возможного дублирования кода, но такой вариант мне тоже не очень нравится. А не нравится потому, что получается, что некоторая более общая сущность (UserService) должна знать о структуре передаваемой в нее более мелкой сущности User. Не ее уровень ответственности, как мне кажется.

На мой взгляд проблема в первую очередь возникает из-за того, что мы относимся к ответу сервера исключительно как к набору данных. Хотя ведь на самом деле он представляет собой список сущностей из предметной области нашего приложения — список пользователей. А если мы говорим о работе с сущностями, то стоит применять наиболее подходящий для этого инструментарий — методы объектно-ориентированного программирования.

Начнем с того, что создадим класс User

// User
export class User {

  readonly id;
  readonly firstName;
  readonly lastName;
  readonly position;

  constructor(userData: UserServerResponse) {
    this.id = userData.id;
    this.firstName = userData.first_name;
    this.lastName = userData.last_name;
    this.position = userData.position;
  }

  fullName(): string {
    return [this.firstName, this.lastName].filter(el => !!el).join(' ');
  }

  avatar(): string {
    return `/users/${this.id}/avatar`;
  }
}

Конструктор класса представляет собой десериализатор ответа сервера. Логика определения полного имени пользователя естественным образом превращается в метод объекта класса User равно как и логика получения аватарки. Теперь переделаем UserService так, чтоб он возвращал нам объекты класса User как результат обработки ответа сервера

// UserService
import {Injectable} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {map} from 'rxjs/operators';

import {UserServerResponse} from './user-server-response.interface';
import {User} from './user.model';

@Injectable()
export class UserService {

  constructor(private http: HttpClient) {

  }

  getUsers(): Observable<User[]> {
    return this.http.get<UserServerResponse[]>('/users')
      .pipe(map(listOfUsers => listOfUsers.map(singleUser => new User(singleUser))));
  }
}

В результате код нашей компоненты становится значительно более чистым и читабельным. Все то, что можно назвать бизнес-логикой, инкапсулировано в моделях и является полностью переиспользуемым.

import {Component} from '@angular/core';
import {UserService} from '../services/user.service';

@Component({
  selector: 'app-user-list',
  template: `
    <div *ngFor="let user of users | async">
      <img [src]="user.avatar()">
      <p><b>{{user.fullName()}}</b>, {{user.position}}</p>
    </div>
  `
})
export class UserListComponent {

  users = this.userService.getUsers();

  constructor(private userService: UserService) {

  }
}

Давайте теперь расширим возможности нашей модели. По идее (в данном контексте мне нравится аналогия с паттерном ActiveRecord [4]) объекты модели пользователя должны быть ответственны не только за получение данных о себе, но и за их изменение. Например, у нас может быть возможность сменить аватарку пользователя. Как будет выглядеть расширенная такой функциональностью модель пользователя?

// User
export class User {

  // ...

  constructor(userData: UserServerResponse, private http: HttpClient, private storage: StorageService, private auth: AuthService) {
    // ...
  }

  // ...

  updateAvatar(file: Blob) {
    const data = new FormData();
    data.append('avatar', file);
    return this.http.put(`/users/${this.id}/avatar`, data);
  }
}

Выглядит неплохо, но модель User теперь использует сервис HttpClient и, вообще говоря, она вполне может подключать и использовать различные другие сервисы — в данном случае это StorageService и AuthService (они не используются, а добавлены просто для примера). Получается, что если мы захотим в каком-нибудь другом сервисе или компоненте использовать модель User, нам для создания объектов этой модели придется подключать все связанные с нею сервисы. Выглядит весьма неудобно… Можно воспользоваться сервисом Injector (его конечно тоже придется внедрять, но он гарантированно будет только один) или вообще создать внешнюю сущность инжектора [5] которую внедрять не придется, но более правильным мне видится делегирования метода создания объектов класса User сервису UserService аналогично тому, как он отвечает за получения списка пользователей.

// UserService
import {Injectable} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {Observable} from 'rxjs';
import {map} from 'rxjs/operators';

import {UserServerResponse} from './user-server-response.interface';
import {User} from './user.model';

@Injectable()
export class UserService {

  constructor(private http: HttpClient, private storage: StorageService, private auth: AuthService) { }

  createUser(userData: UserServerResponse) {
    return new User(userData, this.http, this.storage, this.auth);
  }

  getUsers(): Observable<User[]> {
    return this.http.get<UserServerResponse[]>('/users')
      .pipe(map(listOfUsers => listOfUsers.map(singleUser => this.createUser(singleUser))));
  }
}

Таким образом мы переместили метод создания пользователя в UserService, который уместнее теперь называть фабрикой, и переложили всю работу по внедрению зависимостей на плечи Ангуляра — нам необходимо только подключить UserService в конструкторе.

В конечном итоге давайте уберем дублирование из названий методов и введем соглашения по названиям внедряемых зависимостей. Конечный вариант сервиса в моем видении должен выглядеть так.

import {Injectable} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {Observable} from 'rxjs';
import {map} from 'rxjs/operators';

import {UserServerResponse} from './user-server-response.interface';
import {User} from './user.model';

@Injectable()
export class UserFactory {

  constructor(private http: HttpClient, private storage: StorageService, private auth: AuthService) { }

  create(userData: UserServerResponse) {
    return new User(userData, this.http, this.storage, this.auth);
  }

  list(): Observable<User[]> {
    return this.http.get<UserServerResponse[]>('/users')
      .pipe(map(listOfUsers => listOfUsers.map(singleUser => this.create(singleUser))));
  }
}

А в компонент UserFactory предлагается внедрять под именем User

import { Component } from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {UserFactory} from './services/user.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'app';
  users = this.User.list();

  constructor(private User: UserFactory, private http: HttpClient) {
    http.get('/users').subscribe(res => console.log(res));
  }

}

В этом случае объект класса UserFactory внешне выглятит как класс User со статическими методами для получения списка пользователей и специальным методом создания новых сущностей, а его объекты содержат все необходимые методы бизнес-логики, связанные с конкретной сущностью.

На этом я рассказал все, что хотел. С нетерпением буду ждать обсуждения в комментариях.

Автор: SergeyMell

Источник [6]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/javascript/287413

Ссылки в тексте:

[1] Дмитрий Карловский: https://habr.com/users/vintage/

[2] публикации: https://habr.com/users/vintage/posts/

[3] Angular: https://angular.io/

[4] ActiveRecord: https://ru.wikipedia.org/wiki/ActiveRecord

[5] внешнюю сущность инжектора: https://stackoverflow.com/questions/49507928/how-to-inject-httpclient-in-static-method-or-custom-class

[6] Источник: https://habr.com/post/418463/?utm_campaign=418463