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

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


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


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


[
  {
    "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) объекты модели пользователя должны быть ответственны не только за получение данных о себе, но и за их изменение. Например, у нас может быть возможность сменить аватарку пользователя. Как будет выглядеть расширенная такой функциональностью модель пользователя?


// 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 (его конечно тоже придется внедрять, но он гарантированно будет только один) или вообще создать внешнюю сущность инжектора которую внедрять не придется, но более правильным мне видится делегирования метода создания объектов класса 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) {

  }

}

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


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


Update


Хотел выразить огромную благодарность всем комментирующим. Вы справедливо заметили, что для решения задачи с отображением имени стоило бы использовать Pipe. Я совершенно согласен и сам удивляюсь, почему я не привел это решение. Тем не менее основная цель статьи — показать пример создания модели предметной области (в данном случае это User), которая могла бы в удобной форме инкапсулировать в себе всю бизнес-логику, связанную со своей сущностью. Параллельно попытался решить сопутствующую проблему с внедрением зависимостей.

Комментарии 14

    +2
    То что у Вас получилось из User сложно назвать моделью.
    А для дополнения модели новыми полями лучше использовать мапер.
    Например как на бэке мапятся entity(модель БД) и DTO, так же и на фронте можно сделать прослойку из маперов между приложением и слоем api сервисов.
    Как вариант можно использовать библиотеку automapper-ts
      0
      А я для этих целкей использую библиотеку json2typescript. Описал класс модели, описал аннотациями декораторами поля json объекта, заинжектил сериалайзер в сервис и дернул там
      map(result => this.serializer.deserialize(result, User))
      и всё. И никакого бойлерплейта.
        0

        А мы вот такую штуку используем, весьма удобно https://github.com/ikasparov/tsmodels

          0
          Очень интересно. Я искал нечто подобное, но не попадалось. Спасибо большое.
          0
          constructor(private User: UserFactory, private http: HttpClient) {
              http.get('/users').subscribe(res => console.log(res));
          }
          


          Фабрика писалась чтобы в конечном итоге все равно вызвать http?
          Если нет, поправьте пожалуйста.
            0
            Спасибо большое. Прощелкал. Сейчас поправлю.
            0
            Как на счет варианта пойти angular путем и использовать Pipes.
            Например, код конкатенации имени и фамилии вынести в следующий pipe.

            @Pipe({ name: 'fullName' })
            export class FullName implements PipeTransform {
              transform(value: any): string {
                return value.firstName, value.lastName].filter(el => !!el).join(' ');
              }
            }


            Таким образом решается проблема перегруженности шаблона логикой, повышается читаемость, плюс решается вопрос переиспользования,
            ведь pipes можно вынести в SharedModule и использовать по всему приложению.
            {{ user | fullName}}



            Плюс бросился в глаза следующий фрагмент кода:
            <img [src]="userService.getUserAvatar(user.id)">


            Вызов функций в шаблонах пагубно сказывается на производительности angular-приложений.
            Рекоменую, использовать также pipe + ChangeDetectionStrategy.onPush
            Хорошее 5 минутное видео как раз по этому поводу с последней ngconf конференции:
            www.youtube.com/watch?v=I6ZvpdRM1eQ
              0
              Спасибо. Да, действительно фильтры позволяют решить эту проблему. Но область применимости моделей предметной области значительно шире. Поэтому мне хотелось подвести к ним.
              0

              Почему бы вместо сервиса не использовать ngrx? Тут это само собой напрашивается как мне кажется

                0
                Возможности «модели предметной области» в общем случае значительно шире, чем может дать ngrx, насколько я понимаю.
                  0
                  Ничего не мешает использовать существующую модель внутри ngrx. Изменится лишь способ доступа и хранения данных
                0
                Компонент UserList изначально следовало разбить на компоненты UserList и UserListItem, а для поставленной задачи отображения полного имени замечательно подойдет соответствующий pipe, который очень легко переиспользовать, не создавая при этом «умные данные».
                  0
                  ссылка на аватарку пользователя не приходит сразу в ответе, а формируется на основе id пользователя

                  По хорошему, сервер должен отдавать готовую динамическую ссылку на любой статический контент подобного типа. В вашем случае придется заботиться о сбросе кеша, если она изменена и проверить есть ли она вообще; как реализовать приватность доступа к аватаркам; хранить пути к серверу и т.д. — и всё это не разрешимо на фронте. А нужно просто получить ссылку с сервера и всё ;-)

                  Можно вынести этот метод из компоненты в сервис.

                  Для этого существуют Пайпы.

                  мы переместили метод создания пользователя в UserService

                  Вместо решения проблемы, вы переложили её на плечи бедного сервиса. Ваша первая реализация класса User уже достаточна, далее не нужно было его замусоривать. Внедрив в него кучу сервисов, вы обрекли себя на муки поддержки в будущем (например, в другом месте приложения потребуется просто вывести имя, а у вас ещё вагон зависимостей).

                  Не бойтесь создавать модели и сервисы при первой необходимости. Вы сами того не замечая удивитесь, что сервис, о котором по началу не думали, уже используется в 10 местах. И рефакторинг в сторону упрощения и объединения всегда проще, чем расковыривать монстра с десятком зависимостей и сотней строк.

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

                    Согласен. Но я сталкивался с API подобным приведенному в публикации и такой формат более отвечал поставленным целям статьи.

                    Для этого существуют Пайпы.

                    Полностью согласен и даже добавил небольшой update к статье. Цель статьи была подвести к некоему паттерну модели предметной области. Возможно пример с полным именем не самый удачный в этом контексте.

                    По поводу сервиса и его использования — я обязательно обдумаю ваши замечания. Пока я не сталкивался с проблемами внедрения такого типа моделей. В целом очень благодарен за расширенный комментарий.

                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                  Самое читаемое