Галоўная / Артыкулы

Расплутаванне Angular AuthService: Гейтвэй, Стора, Фасад і Порты

Разбіліце перанасытаны Angular AuthService на гейтвэй, хранальнік сігналаў, порт хранення і фасад, з апдэйтом токенаў пры адной паляці, інтэрсептаром з дыялектам і захоўнікамі, якія можна тэставаць.

Выдавец: REACTAPP.TOP17 хв чытання3866 слоў

Крыніцы: Clean Architecture in Angular: Refactoring the AuthService with Ports & Adapters

Практычна кожны аплікацыя на Angular мае AuthService, і ў багатох проектах гэты файл являецца тым, да чаго ніхто не хоча трапляцца. У ём з’яўляюцца HTTP-запыткі, прытаманнае зберагачэнне токенав, рэакtyўны стан, логіка апавяржэння дадзеных і навігацыя, таму змены хоць аднаго з эўрэнтнаў значыць неабходнасць чытання ўсіх іх. У гэтым артыкуле практычна показана, як пераструктураваць гэты сервіс у маленькія слойвы з однонаправленымі залежнасцямі, а таксама паказаны два рэальныя багі, якія з’явіліся пасля такой пераструктурацыі: неудача апавяржэння дадзеных, якая не выканала выйшчы з абліковання, і інтэрцептор, які вывалюваў токены-носіцы ў API трэціх сторон. Вы атрымаеце схему, яю можна застосаваць да свайгоя аутэнтыкацыйнага функцыяналу, а таксама чыстае розумінне таго, калі не варта ствараць дадатковыя файлы.

Як з’яўляецца „божы“ об’ект AuthService

Ніхто няўмыслова не праектуе службу аутэнтыкацыі з 300 лініяй коду. Усё пачынаецца з метода login(), які вызывае адзін канцэнтрабы. Потым токен павінен быць збераганы гдзі-небудзь, таму з’яўляецца спецыяльнае поле. Чыніцца, токен павінен застацца актуальным пасля перзагрузкі, таму додаецца localStorage. Далей прыходзяць токены для апавядомленняў, потым BehaviorSubject, каб компаненты моглі реагаваць на статус заходжання, а таксама router.navigate('/login') пад час выходу, таму што здавалася, што няма кращага месца для цього. Чэрез палову гада клас начынае імпортуваць Router, HttpClient і localStorage, а для змены стратэгіі зберагання токена патрэбна адказа пра весь файл.

Сконцэнтраваная версія гэтага пункта выхаду выглядае так. Зверніце увагу на тое, сколькі нясупаўзаемых задач выкарыстоваюць адны tap у функцыі login(): апдэйт поля, запіс двух ключоў для зберагача, выдача стану і навігацыя.

@Injectable({ providedIn: 'root' })
export class AuthService {
  private token: string | null = localStorage.getItem('token');
  public isLoggedIn$ = new BehaviorSubject(!!this.token);

  constructor(
    private http: HttpClient,
    private router: Router,
  ) {}

  login(username: string, password: string) {
    return this.http
      .post<any>('https://api.example.com/auth/login', { username, password })
      .pipe(
        tap((res) => {
          this.token = res.accessToken;
          localStorage.setItem('token', res.accessToken);
          localStorage.setItem('refresh', res.refreshToken);
          this.isLoggedIn$.next(true);
          this.router.navigate(['/dashboard']);
        }),
      );
  }
  // ...and 250 more lines
}

Нічага з гэтага тэхнічна не несправна, і ў гэму-то і ўскрытка. Усё працюе пакуль не змяніцца хоць адна з вакалідацый, і тады кожная задача практычна заплутваецца з усімі іншымі. Рэштка гэтага артыкула прыкладвае реструктурызацію функцыяй аутэнтыкацыі ў маленькам проекте Angular 22. Рэзультатны формат не ўсё новы, і кожны элемент мае вядомую назву, але ўпрабавка яго на конкрэтным кодзе з конкрэтнымі багамі паказвае ситуацыю краща, чым дыяграма.

Цэльвы расклад

Адна класа становіцца колькома спецыялізаванымі файламі, якія агрупаваны ў спецыяльную папку core і папку для функцыяй аутэнтыкацыі.

src/app/
  core/
    api-config.ts          ← injectable base URL
    auth-interceptor.ts    ← cross-cutting: attach token, retry on 401
  auth/
    auth-models.ts         ← types
    auth-api.ts            ← HTTP transport, nothing else
    auth-store.ts          ← state, and only state
    auth-storage.ts        ← storage port + adapters
    auth-service.ts        ← orchestration (the facade)
    auth-guard.ts          ← route-level policy
    login/
  dashboard/

Семь маленькіх файлоў замест аднага великага ёсць гарным рашэнням толькі тады, калі кожны файл лёгча зрозумець окрема, чым весь первісны файл. Практычны тэст – чы можна описаць функцію кожнага файла ў адной рэчы. Тут гэта можліва, а схема залежнасцяў настолькі короткая, што ёю можна запісаць калькамі:

auth-interceptor  →  auth-service  →  auth-api    (talks to the network)
   (cross-cutting)      (facade)    →  auth-store  (holds the truth)
                                    →  auth-models (describes the shapes)

Кожны стрэлка паказвае направа. Гэтае правіла, што залежнасці выконваюцца толькі ў аднам направленьцы без цыклаў, і ўладае структурой проекту. Кожны раздзіл нижэй ёсць наследкам гэтага правіла.

Вората: веды пра сеть у аднам месцы

Найнижшы шар абгружае HTTP-канцэнтры і не выкармчвае нічога іншаго. Кожны метод перадае сваія аргументы ў запит з адпаведным типам і вяртае рэзультат, які можна спазіраваць.

@Service()
export class AuthApi {
  private readonly httpClient = inject(HttpClient);
  private readonly baseUrl = inject(API_BASE_URL);

  public login(request: LoginInput) {
    return this.httpClient.post<AuthResponse>(`${this.baseUrl}/auth/login`, request);
  }

  public refresh(refreshToken: string, expiresInMins: number) {
    return this.httpClient.post<RefreshResponse>(`${this.baseUrl}/auth/refresh`, {
      refreshToken,
      expiresInMins,
    });
  }

  public me() {
    return this.httpClient.get<User>(`${this.baseUrl}/auth/me`);
  }
}

Это шаблон Gateway адаптаваны з кнігі Мартіна Фаулера Patterns of Enterprise Application Architecture. Яго частая назва — рэпазітары, але рэпазітары па свойму значэнню — это абстракцыя, якая падобна на колекцыю і якую можна запытваць. Тыпаваны кэркаўкі навакол аддаленых вызоў болей правядліва называецца гэйтвэем.

Праўая з гэтага — це закаменаванне ведомасяпро пратакол. Якщо канец-тап для заходу змініцца з /auth/login на /v2/sessions, змены будуць толькі ў гэтым класе; рэшта прыстановкі ніколі не будзе обрабоўваць URL.

Тое, чаго няма, мае такі ж значэнне, як і тое, што ёсць: няма стану, няма localStorage, няма Router, і няма обработкі адзінакоў, кроме таго, што сам HTTP прыносіць. Кожны метод проста ператварае своия аргументы на Observable, што робіць клас простым для замены на фальшывы ў тэстах.

Базовы ўраджак вводзіцца, а не закодаваны як сталяя частка, праз токен вводу:

export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL');

Токен для адной строчкі можа выглядаць як церэмонія. Аднак так не ёсць, і частка з перехопленням пояснюе, чаму ён стае адной з найцэннейшых частак у гэтым функцыянале.

Даўняе зауважэнне пра декоратар: у фрагментах выкарыстоўваецца @Service() так, як напісана ў проекте. Якщо ваша версія Angular яго не падтрымляе, @Injectable({ providedIn: 'root' }) выпалняе тую ж ролю; пераканайцеся ў дасяглібнай інструкцыі да версіі, яку вы викорыстоўваете.

Магазін: адны власнік для стану сесіі

Магазін храніць сесію і ёсць едынай часткой, якой разрэшана яе змяніць. Ён прытримвае токен доступу, токен апавяржэння і корыстніка ў сігналах.

@Service()
export class AuthStore {
  private readonly storage = inject(AUTH_STORAGE);

  private readonly accessTokenState = signal<string | null>(null);
  private readonly refreshTokenState = signal<string | null>(null);
  private readonly userState = signal<User | null>(null);

  readonly accessToken = this.accessTokenState.asReadonly();
  readonly refreshToken = this.refreshTokenState.asReadonly();
  readonly user = this.userState.asReadonly();

  readonly isAuthenticated = computed(() => this.accessTokenState() !== null);

  public setTokens(access: string, refresh: string): void {
    this.accessTokenState.set(access);
    this.refreshTokenState.set(refresh);
    this.persist();
  }

  public clear(): void {
    this.accessTokenState.set(null);
    this.refreshTokenState.set(null);
    this.userState.set(null);
    this.storage.removeItem(STORAGE_KEY);
  }
  // ...
}

Найважлівейшы факт лёгка ўпусціць з увагі: кожны сигнал, які можна запісваць, ўзяты ў прыватны статус, а кожны публічны сигнал выкладываецца через asReadonly().

Это ўскладненне між командамі та запитамі, якое забезпечваецца системай типаў. Кожны можа чытаць; запіс вядзецца толькі через методы, названыя па мете, такія як setTokens та clear. Компонент не можа безпасэродзе задаць токен. Гэта не просто правілло, якое трэба дазвольваць; гэта немагчыма, таму што тип Signal<T> у прыватным статусе не мае методу set().

Прыклады корысті цягнуцца пад час адлагоджэння. Пытанне „Хто змяніў гэты статус?“ больш не є часткай розследавання, таму што толькі калька названных методаў у аднам файле можа зменіць стан сесіі. Пораўняйце гэта з публічным BehaviorSubject, дзе будь-кой з дзесяткаў компонентаў мог увесці метод next().

Магазін таксама ўтварае едын слой, які взаімаеся з пазначэнням дадзеных, і ён спрыяе всему, што чытаецца з яго, як ненадзеянаму вхіднаму данню.

private restore(): void {
  const raw = this.storage.getItem(STORAGE_KEY);
  if (!raw) return;

  // Storage is user-writable, so treat anything in it as untrusted input.
  try {
    const session = JSON.parse(raw) as Partial<PersistedSession>;
    if (typeof session.accessToken !== 'string' || typeof session.refreshToken !== 'string') {
      this.storage.removeItem(STORAGE_KEY);
      return;
    }
    this.accessTokenState.set(session.accessToken);
    this.refreshTokenState.set(session.refreshToken);
    this.userState.set(session.user ?? null);
  } catch {
    this.storage.removeItem(STORAGE_KEY);
  }
}

localStorage фактычна ўтварае поле для тексту, якое можа рэдагаваць корыстнік, і дадатковыя прыстроі браузера або старэйшыя версіі вашага сапраўднага додатку можу залишыць у яму нечаканыя данні. Выклік JSON.parse без try/catch можа спрычыніць зупінку працы дадатку пад час запуску, а строгі режым TypeScript не будзе паведамляць вас пра гэта, таму што JSON.parse вяртае тип any. Перакананне формы дадзеных і ўдаленне ентры, калі яна не падходзіць, значыць, што зіпсаваная сесія выведзе корыстніка з дадатку, а не заставіць яго сяглядаць пусты экран.

Порт для зберагача: ператварэнне пазначэння дадзеных у змінны элемент

AUTH_STORAGE залежнасць у магазіне не ўособліва сам localStorage. Це інтэрфейс, які мае токен для вставкі, пры чым стандартны фабрык выбірае адпаведную рэалізацыю.

export interface AuthStorage {
  getItem(key: string): string | null;
  setItem(key: string, value: string): void;
  removeItem(key: string): void;
}

export const AUTH_STORAGE = new InjectionToken<AuthStorage>('AUTH_STORAGE', {
  providedIn: 'root',
  factory: () => (typeof localStorage === 'undefined' ? new MemoryStorage() : localStorage),
});

Гэта і є сутнасць концепцыі «Порты та адаптэры» (шэсцявыяй архітектура). Інтэрфейс AuthStorage визначае порт. Рэальныя сховышчы браузера падключаюцца да яго як адаптэры, а клас MemoryStorage, які працюе ў памяці і складаеся з калькі ліній коду, падключаецца як іншы адаптэр.

Порт рашае тры конкрэтныя проблемы:

  1. Тэсты выкарыстоўваюць рэальны магазін. Вставка MemoryStorage дазволяе пераканацца ў рэальнай працэздатнасі сховышча – запісе, перзавантажэнні та вярненні дадзенаў – без неабходнасці чысткі глобальнага стану між тэстамі.
  • Адыяварэнне на баку сервера больш не спрыгвае. На сервере ня існуе localStorage, таму система аўтаматычна пераходзіць на зберагачванне ў памяці.
  • Рашэнні ў аблычыні безпекі стае можна выглядзець. Якщо токен апавяржэння зберагаецца ў localStorage, то будь-які скрыпт, які работае на сторонцы, можа яго прачытаць, што ўскладнюе захист ад XSS-атак. Надзейны спосаб — выкарыстоўваць httpOnly-канік, які стварае бэкенд. Калі зберагачванне адбываецца через спецыяльны порт, такая замена афектуе толькі адзін адаптар, у той час як без гэтага патрэбна замена ў всім коде; пакуль гэта не будзе адбыто, рыска застаецца ў аднам файле з каментам, які гэта поясняе.
  • Трэці пункт ёсць загальны. Хорашая архітэктура не ператварае паслабленага рашэння на хорашае; яна робіць такое рашэння локальным, видавым і простым у скасаванні.

    Фасад: оркестрацыя без адпаведнасці

    AuthService застаецца, але з значна меншымі функцыямі. Ён координуе гейтвэй і хранальнік, а сам нічога не володзіць.

    @Service()
    export class AuthService {
      private readonly authApi = inject(AuthApi);
      private readonly store = inject(AuthStore);
    
      readonly accessToken = this.store.accessToken;
      readonly isAuthenticated = this.store.isAuthenticated;
      readonly user = this.store.user;
    
      public login(username: string, password: string): Observable<AuthResponse> {
        return this.authApi.login({ username, password, expiresInMins: TOKEN_LIFETIME_MINUTES }).pipe(
          tap((response) => {
            this.store.setTokens(response.accessToken, response.refreshToken);
            this.store.setUser({ /* ...map response to User... */ });
          }),
        );
      }
    
      public logout(): void {
        this.store.clear();
      }
    }
    

    Это ўсё тая сама фасадная модэль „Ганг чатырох“, якая таксама выступае ў ролі посередніка: гэты слой — едынственны, які ведае пра існаванне як гейтвэя, так і хранальніка.

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

    Заўважыце, што Router яшчэ не ўключаны. Фасад не здзейснюе навігацію, адтолькі як навігацыя ўскладнюе прэzentаванне даных. Староніца заходжання хочаць перайсці на returnUrl, кнопка выходу з панелі керування — на /login, а дыялог з паведамленням пра заканчэнне сесіі можа вовсе не здзейснювати навігацію. Выклік router.navigate() унутрь logout() змусіў бы застосаваць адну і тую ж політіку для кожнага вызывача. У замене сам компонент, які выклікае функцыю, прыме рашэнне:

    protected logout(): void {
      this.auth.logout();
      void this.router.navigateByUrl('/login');
    }
    

    Цена — два рядкі ў компоненте. У вярнючым падарунке AuthService можа быць выкліканы з інтэрсептараў, гардз і тэстаў без спрычынення перенаправлення, якога ніхто не прасіў.

    Аб’еднанне адночасных апдэйтаў у адзін запит

    Сярод найбольшых аргументаў за викорыстанне фасаду — проблема, якую сложна рашыць іншым спосабам. Панель керування адправляе васьмыя запиты паралельна. Токен даступу выйшаў з часоўным тэрміном паловы мінуты таму, што ўсе васьмы запиты практычна адразу ж отрымаюць код 401. Наўмовітны інтерцептор адпавядае тым, што пачынае васьмыя запыты на апавежэнне. Большасць бэкендов зміняе токен апавежэння пасля кожнага ўжытку, таму калі прыходзяць пазнейшы запыты, токен, які ў іх є, вялікай шанцай вже не дзейсны, і корыстнік выключаецца з сторанніцы, яку ачыніў каля кальколькі секундаз адноўы.

    Рашэнняя — це метод «однойчынага запыту», які таксама называецца агрэгаваннем запытоў або запобежэнням ситуацыі «граду бурзлівага стада»: першы корыстнік пачынае процэс апавежэння, а ўсі іншы прыўяжаюцца да запыту, які вэсьлі ўжо. Тая ж ідея ў налаштаваннях Axios адбіваецца ў стацыяні «Чаму адночасныя коды 401 выключаюць корыстнікаў».

    public refresh(): Observable<RefreshResponse> {
      const refreshToken = this.store.refreshToken();
    
      if (!refreshToken) {
        return throwError(() => new Error('No refresh token available'));
      }
    
      this.refreshRequest$ ??= this.authApi.refresh(refreshToken, TOKEN_LIFETIME_MINUTES).pipe(
        tap((response) => this.store.setTokens(response.accessToken, response.refreshToken)),
        finalize(() => (this.refreshRequest$ = undefined)),
        shareReplay({ bufferSize: 1, refCount: true }),
      );
    
      return this.refreshRequest$;
    }
    

    Тры ўпэраторы павінны выкліквацца ў гэтай спраўе з трох разных прычын:

    • tap зберагае новыя токены. Паколькі ён знаходзится даўэй ад shareReplay, ён выконваецца адна раз пры кожной спяльнай екзекуцыі, а не адна раз пры кожным падпісчыку. Якбы ён быў размешаны пазней, ён перзапісаў бы базу дадзеных для кожнага з восьмі апылальнікаў.
    • finalize скасоўвае значэнне кешаванага поля пасля таго, як спяльны запит завершыўся або з’явіліся якія-небудзь проблемы. Без яго, праз заканчэнне терміну дзейнасці токена, будзе перадацца стары, вялікі ўжо адпаведзень уместо таго, каб быў выкананы новы запыт.
    • shareReplay({ bufferSize: 1, refCount: true }) адправляе ўсім падпісчыкам аднаковы HTTP-запыт і завершае яго, калі пакінуўся последні падпісчык.

    ??= асацыяванне і ўладкае тое, што робіць гэта прыемлівым для адзінаго запуску: толькі той, хто вызывае функцыю і бачыць, што поле пустое, стварае запит, а всі, хто прыходзіць пакуль ён у процесе обробкі, атрымуюць той самы результат. У выніку — адзін сеанс з’ўязку і адна замена токену.

    Сінхронны выклек, який праўільна перайшоў за межы выхаду з системы

    throwError у методе апавяржэння ня ёсць стылістычным выборам. Больш ранняя версія клазу захавання выглядала так:

    if (!refreshToken) throw new Error('No refresh token available');
    

    Ёй легка чытаць, але ёна не працуе. Саме межы слоёў зробілі баг видным. Інтэрцептор выкарыстоўвае refresh() так:

    return auth.refresh().pipe(
      switchMap(/* retry the original request */),
      catchError((refreshError) => {
        auth.logout();
        return throwError(() => refreshError);
      }),
    );
    

    Сінхронны ўтворэнне экспрэсіі throw унутрань refresh() адбываецца калі ўсё яшчэ выконваецца вырачунак auth.refresh(), яшчэ до таго, як будзе вызваны .pipe(). Аператар catchError ніколі не мае можлівасці пазнакоміцца з адной разою, таму auth.logout() таксама ніколі не выконваецца. Едыны случай, для якога была напісана клазуя-захоўніца, — відсутнае токен апавежэння, — быў самэй тым случаем, калі корыстнік заставался у стане частковай аутэнтыкаціі без жадных паведамленняў.

    Верненне throwError(() => ...) ператварае адмову на паведамленне пра адную у стрыме, тады ветка выхіду з системы інтэрсептора выконваецца як і планавалася. Як загальная правіла, метод, якога сігнатура вяртае Observable, должен фіксаваць адмовы через гэты observable, а не праз ўтворэнне экспрэсіі throw.

    Архітектурны ўрок заключаецца у тым, што такіі багі лёгкая сховаюцца ў класе з 300 лініяў, дзе HTTP, кераванне станам і обробка падазроў закінчваюцца адзіны ў аднам, таму што нічога не прымусвае каго-небудзь задаць, што конкрэтны метод обяцвае своім вызывачам. Калі refresh() стае публічным методам-фасадом з аднай чыстаю задачай, пытанне „ці гэта вяртае неўдалы стрім чы выклеквае адзін падазр?“ становіцца явным, як і тэст:

    it('emits an error stream instead of throwing when there is no refresh token', () => {
      const onError = vi.fn();
    
      // A synchronous throw here would bypass the interceptor's catchError/logout branch.
      expect(() => service.refresh().subscribe({ error: onError })).not.toThrow();
      expect(onError).toHaveBeenCalledOnce();
    });
    

    Слойвы не запобегаюць багам. Яны даюць багам месца, дзе яны становяцца виднымі, і гэта ўсё, што яны маюць за ценнасць.

    Інтэрцептор: правілы, якія дзейнуюць у всіх месцах, і токен з апрантам

    Інтэрцептор прыменяе правілы аутанацыі да кожнага выходзячага запиту: прыўязвае токен-носіцель, а пад кодам 401 — апавяржвае і спробуе зноў раз.

    const UNAUTHENTICATED_PATHS = ['/auth/login', '/auth/refresh'];
    
    export const authInterceptor: HttpInterceptorFn = (req, next) => {
      const baseUrl = inject(API_BASE_URL);
    
      if (!isOurApi(req, baseUrl) || isUnauthenticatedRequest(req)) {
        return next(req);
      }
    
      const auth = inject(AuthService);
      const accessToken = auth.accessToken();
      const authReq = accessToken ? withBearer(req, accessToken) : req;
    
      return next(authReq).pipe(
        catchError((error: HttpErrorResponse) => {
          if (error.status !== 401) {
            return throwError(() => error);
          }
    
          return auth.refresh().pipe(
            switchMap(() => {
              const newToken = auth.accessToken();
              if (!newToken) {
                return throwError(() => new Error('Refresh succeeded without token'));
              }
              return next(withBearer(req, newToken));
            }),
            catchError((refreshError) => {
              auth.logout();
              return throwError(() => refreshError);
            }),
          );
        }),
      );
    };
    

    Цепь інтэрсептараў у Angular ёсць прымытай рэалізацыяй прынцыпа «Цепь адпаведнасцяў», пры чым кожная функцыя інтэрсептара модифікуе запит пад час яго праходжання. Оажнейшае — політыка, яку ён выкананы (дадаць аутэнтыкацыйныя даны та спрыймець код 401 як сігнал да апавярнення і новай спробы), праіснуюць да всей API, што робіць яго класычным прыкладам межавага праблемы. Ён не належыць нікаму з окремых вызываючых элементаў. Без інтэрсептара кожны метод службы, які запускаецца на аутэнтыфікованым канцэнтры, быў вынушан павтараць стандартныі код для апавярнення токена і новай спробы, і як толькі логіка апавярнення стане некалькі іншаяй у адной з трыдцяці копій, з’являецца баг, які праказуецца раз у тыдзень у працоўным серавере, але ніколі не праказуецца ў режыме разработкі. Для болей шырокага адкулькавання неудачных запытоў у Angular, адзірніце практыкі проектавання для неудачных запытоў у Angular.

    Тепер уважнаўсцю паглядзіце на клазу «guard clause», яка знаходзится ў верхней частыне функцыі:

    if (!isOurApi(req, baseUrl) || isUnauthenticatedRequest(req)) {
      return next(req);
    }
    

    Сюды і прыносіць практычную корысть зместканы API_BASE_URL. Ён рашае два сэрьозныя проблемы:

    1. Атрыбуты працэсу падачы даных выкрадаюцца да іншых источнікаў. Інтэрцептор, які не пераглядае URL, прыўязвае токен даступу корыстніка да кожнага запиту, які выходзіць, уключаючы завантажэння дадзеных у сховішча, паводкі карт і канцэнтры аналітыки. Чыста з-за таго, што інтэрцептор быў занадта адчулівы, крэдытныя данні потрапляюць у рукі сторонніх аб’ектаў.
    2. Запыты на адновленне, якія носяць выцэнелы токен. Без спісу аўтаматычнага адклэювання запыт на адновленне таксама выходзіць з выцэнелым токеном даступу. Якшы бэкенд гэта ігнаруе; строгі ж адпавядае ўзорам 401, і такім чынам можа выйсці цикл адновлення.

    Калі базовы URL можна ўвести, оба рашэнняяўляюцца адно-рядковымі. Без яго інтэрцептор не мае надзеяго спосабу з’ясавіць, якія запыткі належаць вам.

    Ішчо адна деталь, якая варта з’важыць: практыка перапрытку адправляецца за дапамою next(...), а не праз паўтарны вхід у ланцуг інтэрцептора з самага пачатку. Якщо запытка, якая перапрыткалася, таксама вяртае 401, адказ пра адказку прайшоў да таго, хто яе выканаў, і зупініваецца, таму інтэрцептор не можа ціклізаваць сабе.

    Захоўнікі: доступ да маршруту як правілле

    Пасляпядны слой адпаведзае на аднае пытанне для рутэра: чы можа гэты навігацыйны запытк працаваць?

    export const authGuard: CanActivateFn = (_route, state) => {
      const store = inject(AuthStore);
      const router = inject(Router);
    
      if (store.isAuthenticated()) {
        return true;
      }
    
      return router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } });
    };
    
    export const guestGuard: CanActivateFn = () => {
      const store = inject(AuthStore);
      const router = inject(Router);
    
      return store.isAuthenticated() ? router.createUrlTree(['/dashboard']) : true;
    };
    

    З пунктуазыі безпекі, «страж» — это точка адканацыя правіла: рашэнне пра доступ вырабляецца толькі ў адной точцы. Без яго пераконтролы автанаціі часта прабываюць у констрактарах компанентоў, забываюцца на маршрутах, якія былі даданы пад тым прытыскам, каб усё было гатова ў час, і ствараюць экраны, якія на короткі час адобразваюць інтэрфейс, які выглядае прыватным, перш чым перадаць на іншую сторону.

    Верненне UrlTree заместо імператыўнага навігавання — це свядомы выбор. Рутэр абмовяецца ад спробы навігавання і выконвае перадачу на іншую сторону ў адны крок, таму захаваны маршрут ніколі не адобразваецца на экране. Другі «страж», guestGuard, ўособлівае тое ж самае: яны не дазволяюць аутанавіраваным корыстнікам прайсці на сторону заходжання.

    Потым «стражы» прыўязваюцца да маршрутаў:

    export const routes: Routes = [
      {
        path: 'login',
        canActivate: [guestGuard],
        loadComponent: () => import('./login/login').then((m) => m.Login),
      },
      {
        path: 'dashboard',
        canActivate: [authGuard],
        loadComponent: () => import('./dashboard/dashboard').then((m) => m.Dashboard),
      },
      { path: '', pathMatch: 'full', redirectTo: 'dashboard' },
      { path: '**', redirectTo: 'dashboard' },
    ];
    

    Параметр запиту returnUrl, які задае authGuard, доходзіць да компонента аблогіну без якога-лібо ручнага коду ActivatedRoute. За дапамою provideRouter(routes, withComponentInputBinding()) параметры запиту прыязаны да адпаведных вхідных сигналаў:

    readonly returnUrl = input('/dashboard');
    // ...
    this.auth.login(username, password).subscribe({
      next: () => this.router.navigateByUrl(this.returnUrl()),
      error: () => { /* ... */ },
    });
    

    Адны значымы факт з проекту: яшчэ до стварэння гарда, стоўкі вялічыны isAuthenticated, якая была вычыслюваным сигналам, нічога не чытала. Такія невыкарыстаныя станы ўскладнююць дизайн. Гэта не безнэшкодна; гэта обява, яку аплікацыя ўсё ще не выпалоўвала.

    Што даёць вам розпад на шары пад час тэставання

    Сярод адзінаеўшых прынад — гэта мінімальная колькасць наладк, якія трэба для тэстав. Не патрабуецца ніякіх фікстураў для компанентаў, нічога з localStorage, а таксама не патрабуецца ручнага стварэння макаў HttpClient. Наведаная нижэй наладка забезпечвае бэкенд для тэстав HTTP у Angular і заменяе роўна два токены — базовую URL і порт для зберагання.

    beforeEach(() => {
      TestBed.configureTestingModule({
        providers: [
          provideHttpClient(),
          provideHttpClientTesting(),
          { provide: API_BASE_URL, useValue: BASE_URL },
          { provide: AUTH_STORAGE, useValue: new MemoryStorage() },
        ],
      });
      service = TestBed.inject(AuthService);
      store = TestBed.inject(AuthStore);
      http = TestBed.inject(HttpTestingController);
    });
    
    it('shares one in-flight request across concurrent refresh calls', () => {
      store.setTokens('access-1', 'refresh-1');
    
      service.refresh().subscribe();
      service.refresh().subscribe();
    
      const request = http.expectOne(`${BASE_URL}/auth/refresh`);
      request.flush({ accessToken: 'access-2', refreshToken: 'refresh-2' });
    
      expect(store.accessToken()).toBe('access-2');
    });
    

    Тэст single-flight вызывае refresh() два разы, а пасля таго викорыстоўвае http.expectOne, які не працюе, якщо было адправлена больш за адну падходячую запытку. Гэта пераканваецца ў калякві лініяў, і гэта заставаецца актуальным нават пасля таго, як хтось перерабіць refresh() чырэз годзину. Весь тэстовы комплект складаецца з двух інжектаваных токенаў, што якраз і было метай портаў.

    Што гэты дизайн не ёсць

    Точнае вызначэнне меж патэрану такое ж корыстнае, як і його назва.

    Это не Redux і не NgRx. Не існуець акцыяў, редактараў, эфектаў чы цыклу dispatch. AuthStore — это інкапсульаваны стан з методамі, пазначанымі намерамі. Якщо вам трэба налагоджэнне з выкарыстоўванням механізма паўтарнага адыграння сюжэту, коордынацыя эфектаў між разнымі функцыямі чы серыяваны лог акцыяў для паўтарнага відтворэння, перайдзіце на NgRx; такая фасадная структура значыць, што компоненты не зазнаюць перашкоду ад такога пераключэння.

    Это не сапраўдная архітектура Clean Architecture. Тут не будзе класаў для апісання сцэнарыяў викорыстоўвання, а модэль домэны не выдзеляецца ад формата даных. Тып User у файле auth-models.ts — это проста формат адпаведнай вясі з API. Строгі падход архітектуры Clean Architecture выклікаў бы стварэнне окремых типаў і ўзаемную перакладку межыма. Калі є толькі калькі паўней, такы шар мапавання коштуе больш, чым прыносіць корысты, але калі формат API і модэль домэны пачнуць адхіляцца, саме тут і трэба ўвести такі шар.

    Гэта не безкоштовна. Семь файлаў, калькі непрацяглых спосабаў выкарыстоўвання дадзеных і токэн для вставкі адноўы рошту — гэта рэальныя затраты. У протатэпе з двума экранамі гэта ўжо перастаёт быць разумным рашэнням, а стварэнне адного вялікага сервісу є правым выборам. Тачка выраўнавання затрат зазвычай наступае тады, калі два функцыяналы патрабуюць адной і той жа стану сесіі, або калі празпачатку трэба поясніць логіку адновы колеге.

    Заключэнне

    Усё падзеянне дызайна сводзіцца да адной правілы: залежнасці пераходзяць толькі ў аднам напрамку, і кожны аспект рэалізуецца толькі ў аднам файле.

    interceptor → service → { api, store } → models
    

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

    Гэтыя абмежнення зробілі синхронны вызов throw падчас паўвыходу з системы прычынай незусім успешнага тэсту. Яны ператворылі запитанне „Калькі запросы несу токен носія?“ у запитанне, на якое ёсць адказ у адной лініі. Кроме таго, гэта зробіць пераход з localStorage на кукі типу httpOnly простым зменшэнням адпатэра, а не пашукам у всім коде.

    Назвы гэтых патэранаў звучаць вялікадушна: Gateway, Facade, Ports and Adapters, Chain of Responsibility, Policy Enforcement Point. Аднак усе яны выражаюць адную і тую ж ідею — надаць кожнай рашынцы конкрэтны месца і зробіць напрамак залежнасцяў явным. Усе інша выйшвае з гэтага.