Около 5 лет назад я пересел с Реакта на второй Ангуляр и первое, чего мне там не хватило был модуль angular-resource из первого Ангуляра. Вменяемых аналогов я не нашел, поэтому за неделю написал свою библиотеку. Решение оказалось настолько удачным, что практически без изменений дошло до сегодняшнего дня. Используется в куче проектов, работает стабильно (не смотря на то, что до сих пор там нет ни одного теста), в общем, есть о чем рассказать.
Промисы наше всё
Пойдем от простого к сложному. Чаще всего в коде можно увидеть такое использование:
import { Component } from '@angular/core'; import { UsersResource } from './_resources/users.resource'; @Component({ selector: 'app-root', templateUrl: './app.component.html' }) export class AppComponent { users = [] constructor(private usersResource: UsersResource) {} loadUsers() { this.usersResource.query().then(users => { this.users = users; }); } }
Вспоминается старый добрый первый Ангуляр. Значение получается в нативном всем понятном промисе и присваивается свойству компонента. Вся эта история с RxJS, который команда Ангуляра пытается нам продать, мне изначально показалась сомнительной, поэтому по-умолчанию работаем с промисами, которых в 90% случаев хватает за глаза.
Теперь посмотрим как выглядит UsersResource. Если приложение работает с грамотным REST API, то большинство ресурсов будут выглядеть так:
import { Injectable } from '@angular/core'; import { ApiResource, HttpConfig } from './_resources/api.resource'; @Injectable() @HttpConfig({ url: '/users/:id', }) export class UsersResource extends ApiResource {}
Далее посмотрим что из себя представляет ApiResource. Там больше кода, но и пишется он обычно один раз в начале проекта.
import { Injectable } from '@angular/core'; import { ReactiveResource } from '@angular-resource/core'; import { HttpConfig, Get, Post, Put, Patch, Delete } from '@angular-resource/http'; @Injectable() @HttpConfig({ host: 'http://127.0.0.1', headers: {}, withCredentials: true, transformResponse(response, options) { if (Array.isArray(response?.data) && options.isArray) { return response.data } return response } }) export class ApiResource extends ReactiveResource { query = Get({ isArray: true }) get = Get() create = Post() update = Patch() replace = Put() delete = Delete() } export from '@angular-resource/core' export from '@angular-resource/http'
Видим, что работает обычное наследование. Причем декораторы тоже наследуются (в UsersResource мы расширили наш конфиг параметром url). Таким образом можем расширить любой наш ресурс дополнительным методом и/или конфигурацией. Очень удобно.
import { Injectable } from '@angular/core'; import { ApiResource, HttpConfig, Get } from './_resources/api.resource'; @Injectable() @HttpConfig({ url: '/users/:id', }) export class UsersResource extends ApiResource { getMeta = Get({ url: '/users/meta-data' }) }
RxJS иногда тоже полезен
Итак, у нас есть модуль для работы с REST API, как в первом Ангуляре. Такое себе достижение конечно и не стал бы писать статью только ради этого, так что идем дальше. Все ресурсы наследуются от некого класса ReactiveResource, который содержит под капотом шину событий, построенную на основе ReplaySubject с сохранением последнего состояния. Это позволяет делать интересные вещи. Например, мы можем легко переписать наш код на событийный манер:
import { Component } from '@angular/core'; import { UsersResource } from './_resources/users.resource'; @Component({ selector: 'app-root', templateUrl: './app.component.html' }) export class AppComponent { users = [] constructor(private usersResource: UsersResource) {} loadUsers() { this.usersResource.query() this.usersResource.actions.subscribe(action => { if (action.type === 'query') { this.users = action.payload } }); // или используя синтаксический сахар this.usersResource.action('query').subscribe(payload => { this.users = payload }); // и даже можем послать своё событие в поток ресурса this.usersResource.action('query').next([]) } }
Здесь, как и завещала команда Ангуляра, мы работаем с http-запросами как с потоками данных. Легко комбинируем их с реактивными формами и можем с помощью RxJS операторов разрулить ситуацию любой сложности.
До этого мы работали с http-запросами, но раз уж эта вундервафля основана на шине событий, то кто мешает работать с Вебсокетами, например? Правильно, никто. Мы сами можем написать какие угодно декораторы-адаптеры. Из коробки помимо HttpConfig доступны WebSocketConfig, SocketIoConfig и LocalStorageConfig, так что давайте напишем чат. Создадим новый ресурс:
import { Injectable } from '@angular/core'; import { ReactiveResource } from '@angular-resource/core'; import { HttpConfig, Get } from '@angular-resource/http'; import { SocketIoConfig, CloseSocketIo, OpenSocketIo, SendSocketIoEvent } from '@angular-resource/socket-io'; @Injectable() @HttpConfig({ host: 'http://127.0.0.1:3000', url: '/messages/:id' }) @SocketIoConfig({ url: 'ws://127.0.0.1:3000' }) export class ChatResource extends ReactiveResource { getMessages = Get() connect = OpenSocketIo() disconnect = CloseSocketIo() sendMessage = SendSocketIoEvent('sendMessage') }
Компонент примет следующий вид:
import { Component } from '@angular/core'; import { ChatResource } from './_resources/chat.resource'; @Component({ selector: 'app-root', templateUrl: './app.component.html' }) export class AppComponent { messages = [] constructor(private chatResource: ChatResource) { this.chatResource.getMessages() .then(messages => { this.messages = messages }) .catch(error => { console.log('HTTP error', error) }) this.chatResource.connect() .catch(error => { console.log('WS error', error) }) // Предполагаем, что SocketIO-сервер шлет все новые сообщения (в т.ч. наше) в событии 'newMessage' this.chatResource.action('newMessage').subscribe(message => { this.messages.push(message) }) } sendMessage() { this.chatResource.sendMessage({ text: 'My message' }) .then(() => { console.log('Message was sended by SocketIO') }); } }
Здесь мы загружаем историю сообщений HTTP-запросом, а дальше взаимодействуем с сервером через Вебсокеты и все это в одном ресурсе.
В заголовке статьи было что-то про NgRX
И раз уж пошла такая пьянка, давайте напишем какой-нибудь примитивный счетчик (догадываетесь к чему клоню?). Для этого есть особый декоратор StateConfig. Мы же храним внутри ReplaySubject последнее состояние ресурса, почему бы это не использовать?
import { Injectable } from '@angular/core'; import { ReactiveResource, StateConfig } from '@angular-resource/core'; @Injectable() @StateConfig({ initialState: { counter: 0, updatedAt: 0 }, updateState: (state, action) => { // Reducer if (action.error) { return state } switch (action.type) { case 'increase': return {...state, counter: state.counter + action.payload} case 'decrease': return {...state, counter: state.counter - action.payload} case 'updateAt': return {...state, updatedAt: action.payload} default: return state } } }) export class CounterStore extends ReactiveResource { // Actions increase = (num) => this.action('increase').next(num) decrease = (num) => this.action('decrease').next(num) updateAt = (date) => this.action('updateAt').next(date) }
Ничего не напоминает? Декоратор — идеальное место для написания редьюсера, он на психологическом уровне подсказывает, что ничего сложного там городить не стоит.
Взглянем на код компонента:
import { Injectable } from '@angular/core'; import { CounterStore } from './_resources/counter.store'; @Component({ selector: 'app-root', templateUrl: './app.component.html' }) export class AppComponent { counter = 0 updatedAt = 0 constructor(private counterStore: CounterStore) { this.counterStore.state.subscribe(state => { this.counter = state.counter this.updatedAt = state.updatedAt }); // Effects this.counterStore.action('increase', 'decrease').subscribe(payload => { this.counterStore.updateAt(Date.now()) }); } increase(num) { this.counterStore.increase(num) } decrease(num) { this.counterStore.decrease(num) } }
Видим, что помощью уже известного нам способа подписки на события запросто организовать эффекты, как это делается в NgRX. Впрочем можно было бы написать и так:
this.counterStore.state.subscribe(state => { this.counter = state.counter this.updatedAt = state.updatedAt this.counterStore.updateAt(Date.now()) });
И это даже не вызовет бесконечный цикл (из-за того, что обновление даты вызывает обновление стейта), так как есть защита от дурака и если две одинаковые микротаски появляются в одной фазе цикла, то вторая и последующие отбрасываются.
Используем все возможности
Соберем в кучу наши знания и перепишем чат, добавив туда некоторые плюшки. ChatResource примет такой вид:
import { Injectable } from '@angular/core'; import { ReactiveResource, StateConfig } from '@angular-resource/core'; import { HttpConfig, Get } from '@angular-resource/http'; import { SocketIoConfig, CloseSocketIo, OpenSocketIo, SendSocketIoEvent } from '@angular-resource/socket-io'; @Injectable() @HttpConfig({ host: 'http://127.0.0.1:3000', url: '/messages/:id' }) @SocketIoConfig({ url: 'ws://127.0.0.1:3000' }) @StateConfig({ initialState: { messages: [], isLoading: false, isError: false }, updateState: (state, action) => { switch (action.type) { case 'getMessages:start': return {...state, isLoading: true} case 'getMessages': return !action.error ? {...state, messages: action.payload, isError: false, isLoading: false} : {...state, isError: true, isLoading: false} case 'newMessage': return {...state, messages: [...state.messages, action.payload]}; case 'connect': return {...state, isError: !!action.error} default: return state; } } }) export class ChatResource extends ReactiveResource { getMessages = Get(); connect = OpenSocketIo(); disconnect = CloseSocketIo(); sendMessage = SendSocketIoEvent('sendMessage'); constructor() { super() this.error('getMessages').subscribe(error => { setTimeout(() => { console.log('HTTP reconnect...') this.getMessages() }, 5000) }) this.error('connect').subscribe(error => { setTimeout(() => { console.log('WS reconnect...') this.connect() }, 7000) }) } }
Думаю, вы уже догадались, что название событий берутся из названий методов в ресурсах. Если предполагается отложенное получение данных, то инициализирующее событие будет иметь суффикс :start
Теперь вся логика хранится в ресурсе и компонент будет ожидаемо маленьким:
import { Injectable } from '@angular/core'; import { ChatResource } from './_resources/chat.resource'; @Component({ selector: 'app-root', templateUrl: './app.component.html' }) export class AppComponent { messages = [] isLoading = false isError = false constructor(private chatResource: ChatResource) { this.chatResource.state.subscribe(state => { this.messages = state.messages this.isLoading = state.isLoading this.isError = state.isError }); this.chatResource.connect() this.chatResource.getMessages() } sendMessage() { this.chatResource.sendMessage({ text: 'My message' }); } }
В принципе, можно было бы переписать компонент в ангуляровском стиле, а в шаблоне использовать | async:
this.messages = this.chatResource.state.pipe(messageSelector) this.isLoading = this.chatResource.state.pipe(isLoadingSelector) this.isError = this.chatResource.state.pipe(isErrorSelector)
но мне такой подход не нравится. Во-первых, в шаблоне появляется логика не относящаяся к отображению, что нарушает парадигму MCV, во-вторых, люди с React/Vue бэкграундом или бэкендщики, которым придется читать этот код, вам точно спасибо не скажут (привет ребятам из Тинькова и другим, кто так делает). Мемоизация, которую мы получаем в этом случае, зачастую не дает ощутимого выигрыша в скорости, да и реализовать ее можно в другом месте. Тем более вы сами выбрали Ангуляр своим фреймворком, так что не жалуйтесь.
Итоги
В общем, это и вся демонстрация. На первый взгляд такой подход к проектированию приложений кажется чересчур гибким и приносящим хаос в проект. На деле всё наоборот. Разработчики в большинстве случаев просто пишут на промисах, как показано в начале статьи и в ус не дуют. Лишь когда задача становится действительно сложной, то решается как ее разрулить на событиях, нужен ли стор и прочее. А так как всё наше взаимодействие с сервером (и не только) унифицировано, то переход из одного стиля программирования в другой не представляет труда. Переписывание логики страницы со сложной формой с десятками контролов с промисов на события у меня занял день. Сложно представить сколько дополнительного времени ушло бы, если бы я сразу использовал события или редьюсеры со стором. Простые вещи все же лучше писать просто.
Никогда не рассматривал этот инструмент как что-то долгоживущее, но минуло пять лет, а воз и ныне там. За это время появился NgRX, появился MobX и что-то ещё, но чего-то кардинально лучшего, к сожалению не придумали. Или я об этом не знаю (поделитесь в комментариях). Поэтому и написал эту статью.
Эта незамысловатая штука лежит на Гитхабе https://github.com/tamtakoe/oi-angular-resource, можете поиграться. Документация там так себе — извиняйте. Работает надежно, но я не проводил всеобъемлющего тестирования, особенно методов, которыми не пользовался в жизни; да и некоторые штуки типа адаптера для Вебсокетов написаны «чтобы было».
ссылка на оригинал статьи https://habr.com/ru/articles/752398/
Добавить комментарий