Angular

Я из тех, кто обычно очень нетерпелив, когда менеджер раскрывает функции нового продукта. После спринта я задаю этот ужасающий, трепещущий вопрос:

… API уже готов?..

И ответ часто отрицательный. Иногда коллеги по бэкенду заняты другими делами и не могут предоставить даже заглушку. Но ничего страшного! Если у вас есть жизнеспособный интерфейс объекта переноса данных, с которым согласны коллеги, вы можете начать создавать новые функции.

Минимизируем изменения для интеграции: используем Injectables и будем работать на двух клиентах API одновременно:

  • Первый  —  заглушка, назовём её MockPriceApiService.
  • Второй  —  реальный сервис, он будет поставляться приложением, когда API будет готов. Его мы назовём PriceApiService.
  • Классы реализуют интерфейс, устанавливающий контракт между API.

Ещё раз. Я создам минимальное приложение для криптовалюты. Оно будет работать как с поддельными ценами, так и с реальным сервисом Coincap. Читать лень? Посмотрите готовый проект Stackblitz!

Общий интерфейс

Во-первых, мы хотим определить общий интерфейс для классов API. Создаём ApiService:

import { Observable } from 'rxjs';

export interface PriceApi {
  getPrice(currency: string): Observable<string>;
  unsubscribe(): void;
}

Как видите, сервисам нужно только два общих метода:

  • Первый  —  подписка на поток цен.
  • Второй  —  отписка от него.

Заглушка

Начнём с сервиса-заглушки. Вот, что нужно реализовать:

  • Получение потока случайных чисел (цен). Он будет основан на базовых ценах, чтобы приблизить числа к реальным.
  • Отписка от потока.

Приложение поддерживает 3 валюты: биткоин, лайткоин и эфириум.

const BASE_PRICES = {
  bitcoin: 11000,
  litecoin: 130,
  ethereum: 300
};

const randomize = (price: number) => {
  return (Math.random() * 2) + price;
};

@Injectable()
export class MockPriceApiService implements PriceApi {
  static latency = 250;
  private unsubsciptions$ = new Subject();

  public getPrice(currency: string): Observable<string> {
    return timer(0, MockPriceApiService.latency).pipe(
      delay(1000),
      map(() => BASE_PRICES[currency]),
      map((price: number) => randomize(price)),
      map((price: number) => price.toFixed(5)),
      takeUntil(this.unsubsciptions$),
    )
  }

  public unsubscribe() {
    this.unsubsciptions$.next();
  }
}

О методе getPrice:

  • Метод принимает параметр currency— имя валюты.
  • Таймер срабатывает через 250 мс.
  • Замораживаем оповещённую Observable на 1 секунду, имитируя задержку сети.
  • Берём BASE_PRICE выбранной валюты и каждый раз возвращаем случайное число.
  • Повторяем до оповещения от Subject.

Реальный сервис

Как мы уже говорили, вашей команде может потребоваться некоторое время, прежде чем будет предоставлен реальный API для внедрения в ваш интерфейс. Если команда уже согласилась с интерфейсом, ничто не мешает вам реализовать API заранее и создать реальный сервис вместе с имитацией.

Чтобы завершить пример, создадим реальный сервис и возьмём цены у Coincap. Будем использовать WebSocket для потоковой передачи цен нашим клиентам.

Два открытых общих метода будут:

  • Создавать соединение WebSocket и поток цен через Observable.
  • Закрывать соединение при вызове unsubscribe. Поток перестаёт отправлять цены.
const WEB_SOCKET_ENDPOINT = 'wss://ws.coincap.io/prices/';

@Injectable()
export class PriceApiService implements PriceApi {
  private webSocket: WebSocket;

  public getPrice(currency: string): Observable<string> {
    return this.connectToPriceStream(currency);
  }

  public unsubscribe() {
    this.webSocket.close();
  }

  private connectToPriceStream(asset: string): Observable<string> {
    this.createConnection(asset);

    return new Observable(observer => {
      const webSocket = this.webSocket;

      webSocket.onmessage = (msg: MessageEvent) => {
        const data = JSON.parse(msg.data);
        observer.next(data[asset]);
      };

      return {
        unsubscribe(): void {
          webSocket.close();
        }
      };
    });
  }

  private createConnection(asset: string) {
    if (this.webSocket) {
      this.webSocket.close();
    }

    this.webSocket = new WebSocket(
      WEB_SOCKET_ENDPOINT + `?assets=${asset}`
    );
  }
}

Данные для сервиса

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

  • Мы создаем свойство price, которому назначаем поток.
  • Когда пользователь меняет валюту, мы вызываем метод отписки, если валюта не выбрана.
@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class AppComponent {
  public price$: Observable<string | undefined>;
  public currency$ = new BehaviorSubject<string | undefined>(undefined);

  constructor(private api: PriceApiService) { }

  ngOnInit() {
    this.price$ = this.currency$.pipe(
      mergeMap((currency: string | undefined) => {
        return currency ? this.api.getPrice(currency) : of(undefined);
      }),
      shareReplay(1)
    );
  }

  onCryptoSelected(currency: string) {
    if (this.currency$.value) {
      this.api.unsubscribe();
    }

    this.currency$.next(currency);
  }
}

Вот, что в шаблоне:

  • Отображаем цену.
  • Если цена не определена, но валюта выбрана, значит, подписка действует. Показываем загружаемое сообщение.
  • Валюта не выбрана? Тогда сообщение пустое.
<div class='mt-5'>
  <crypto-selector (selected)="onCryptoSelected($event)"></crypto-selector>

  <div class='price'>
    {{ price$ | async }}
  </div>

  <ng-container *ngIf="(price$ | async) === undefined && (currency$ | async)">
    <div class='alert alert-info mt-2'>
      Awaiting for Price...
    </div>
  </ng-container>

  <ng-container *ngIf="!(currency$ | async)">
    <div class='alert alert-warning mt-2'>
      No Crypto Subscribed Yet
    </div>
  </ng-container>
</div>

Переключаем сервисы

Здесь  —  магия. Благодаря внедрению зависимости мы можем предоставить нужный нам сервис, передав ему имя класса. Если useMocks истинна, используемMockPriceApiService, иначе PriceApiService.

// в реальном приложении может быть environment.useMocks.
const useMocks = true;

@NgModule({
  imports: [BrowserModule, CommonModule],
  declarations: [AppComponent, CryptoSelectorComponent],
  bootstrap: [AppComponent],
  providers: [
    {
      provide: PriceApiService,
      useClass: useMocks ? MockPriceApiService : PriceApiService
    }
  ]
})
export class AppModule { }

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

Внедрение зависимости

Кроме useClass, вы можете использовать:

  • useValue для строк, чисел и так далее.
  • useFactoryдля передачи функций.
  • InjectionToken для отличных от классов значений.

Полное описание смотрите в документации Angular.

Проект Stackblitz.

Заключение

Внедрение зависимостей Angular  —  мощный инструмент, используемый для разных задач:

  • Прототипирование.
  • Переключатели возможностей приложений.
  • Заглушки сервисов для тестов.
  • Конфигурирование.

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

Читайте также:


Перевод статьи Giancarlo Buomprisco: API-less Prototyping with Angular