Всем привет, меня зовут Сергей и я 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"
}
]
а отобразить его нужно как на картинке
Выглядит несложно — давайте попробуем. Разумеется для получения этого списка у нас будет сервис 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
может быть пустым и если мы оставим компонент в таком виде, то будем получать нежелательные пробелы перед запятой. Какие есть варианты решения?
-
Можно немного поправить шаблон отображения
<p> <b>{{[user.first_name, user.last_name].filter(el => !!el).join(' ')}}</b>, {{user.position}} </p>
Но таким образом мы перегружаем шаблон логикой, и он становится плохочитаемым даже для такой простой задачи. А ведь приложению еще расти и расти...
-
Вынести код из шаблона в класс компоненты, добавив метод типа
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, private http: HttpClient) {
http.get('/users').subscribe(res => console.log(res));
}
}
В этом случае объект класса UserFactory
внешне выглятит как класс User
со статическими методами для получения списка пользователей и специальным методом создания новых сущностей, а его объекты содержат все необходимые методы бизнес-логики, связанные с конкретной сущностью.
На этом я рассказал все, что хотел. С нетерпением буду ждать обсуждения в комментариях.
Автор: SergeyMell