Около 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% случаев хватает за глаза.

Примечание для тех, кто считает, что такой подход больше не Angular-way

Несколько лет назад на одной из конференций общался по этому поводу с Игорем Минаром — ключевым разработчиком Ангуляра. Он поддерживает такой подход, но задача не приоритетная и у команды просто нет ресурсов на написание подобной библиотеки. К сожалению, он так и не прочитал мое сообщение с ссылкой на этот проект.

Теперь посмотрим как выглядит 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(messagesSelector)
this.isLoading = this.chatResource.state.pipe(isLoadingSelector)
this.isError   = this.chatResource.state.pipe(isErrorSelector)

но мне такой подход не нравится. Во-первых, в шаблоне появляется логика не относящаяся к отображению, что нарушает парадигму MCV, во-вторых, люди с React/Vue бэкграундом или бэкендщики, которым придется читать этот код, вам точно спасибо не скажут (привет ребятам из Тинькова и другим, кто так делает). Мемоизация, которую мы получаем в этом случае, зачастую не дает ощутимого выигрыша в скорости, да и реализовать ее можно в другом месте. Тем более вы сами выбрали Ангуляр своим фреймворком, так что не жалуйтесь.

Итоги

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

Никогда не рассматривал этот инструмент как что-то долгоживущее, но минуло пять лет, а воз и ныне там. За это время появился NgRX, появился MobX и что-то ещё, но чего-то кардинально лучшего, к сожалению не придумали. Или я об этом не знаю (поделитесь в комментариях). Поэтому и написал эту статью.

Эта незамысловатая штука лежит на Гитхабе, можете поиграться. Документация там так себе — извиняйте. Работает надежно, но я не проводил всеобъемлющего тестирования, особенно методов, которыми не пользовался в жизни; да и некоторые штуки типа адаптера для Вебсокетов написаны «чтобы было».

Комментарии (6)


  1. hel1n
    04.08.2023 07:24

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

    Я не разработчик, но мне, кажется, тут утечка памяти (this.chatResource.state подписались и не отписались) или я ошибаюсь?


    1. definitelyfakename
      04.08.2023 07:24

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

      Upd: в репозитории пример именно такой)


    1. tamtakoe Автор
      04.08.2023 07:24

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


  1. XXLink
    04.08.2023 07:24

    Категорически нерабочее решение. Отписок нет, типизации никакой.


    1. tamtakoe Автор
      04.08.2023 07:24

      Делайте отписки, добавьте типизацию, кто мешает?

      export class UsersResource extends ReactiveResource {
      get: HttpMethod<GetUserRequest, User[]> = Get();
      }
      Статья и так длинная вышла, чтобы там все очевидные вещи описывать


  1. muturgan
    04.08.2023 07:24

    Лично я никогда не использовал ngrx потому что я всегда использовал ngxs (хотя конечно и он не нужен)