Расплутаванне Angular AuthService: Гейтвэй, Стора, Фасад і Порты
Разбіліце перанасытаны Angular AuthService на гейтвэй, хранальнік сігналаў, порт хранення і фасад, з апдэйтом токенаў пры адной паляці, інтэрсептаром з дыялектам і захоўнікамі, якія можна тэставаць.
Крыніцы: 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, які працюе ў памяці і складаеся з калькі ліній коду, падключаецца як іншы адаптэр.
Порт рашае тры конкрэтныя проблемы:
- Тэсты выкарыстоўваюць рэальны магазін. Вставка
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. Ён рашае два сэрьозныя проблемы:
- Атрыбуты працэсу падачы даных выкрадаюцца да іншых источнікаў. Інтэрцептор, які не пераглядае URL, прыўязвае токен даступу корыстніка да кожнага запиту, які выходзіць, уключаючы завантажэння дадзеных у сховішча, паводкі карт і канцэнтры аналітыки. Чыста з-за таго, што інтэрцептор быў занадта адчулівы, крэдытныя данні потрапляюць у рукі сторонніх аб’ектаў.
- Запыты на адновленне, якія носяць выцэнелы токен. Без спісу аўтаматычнага адклэювання запыт на адновленне таксама выходзіць з выцэнелым токеном даступу. Якшы бэкенд гэта ігнаруе; строгі ж адпавядае ўзорам 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. Аднак усе яны выражаюць адную і тую ж ідею — надаць кожнай рашынцы конкрэтны месца і зробіць напрамак залежнасцяў явным. Усе інша выйшвае з гэтага.