Angular имеет отличный инструмент для ограничения навигации между страницам из коробки. Но, как и любом большом проекте, у него есть свои подводные камни. Сегодня я расскажу вам об одном из них.
Перед прочтением статьи освежите знания по Angular Guards (далее гарды). Статья будет о CanActivate
, но это относиться так же и к остальным гардам. Теперь перейдем к истории обнаружения приоритетов.
Первая проба
Недавно я разрабатывал новую фичу для админов. Требовалось ограничить доступ на страницу — пускать пользователя, если у него включен определенный фича флаг и он администратор, в противном случае редирект на главную.
Кажется, что задача тривиальная. В моем мозгу сразу появилось решение с помощью гарда CanActivate
. Допустим, уже существуют два сервиса на эти два условия. Далее вызываем нужные методы, объединяем их в один Observable
и дальше если хоть один вернул false
, то редиректим на главную, если оба true
, то пускаем пользователя к странице.
@Injectable()
export class AdminGuard implements CanActivate {
constructor(
private readonly featureToggleService: FeatureToggleService,
private readonly userService: UserService,
private readonly router: Router
) {}
canActivate(): Observable<boolean | UrlTree> {
const isAdminPageEnabled$ =
this.featureToggleService.isAdminPageEnabled$.pipe(startWith(null));
const isAdmin$ = this.userService.isAdmin$.pipe(startWith(null));
return combineLatest([isAdminPageEnabled$, isAdmin$]).pipe(
first(
conditions =>
conditions.some(condition => condition === false) ||
conditions.every(Boolean)
),
map(
([isAdminPageEnabled, isAdmin]) =>
(isAdminPageEnabled && isAdmin) || this.router.createUrlTree(['/'])
)
);
}
}
Код работает, но если присмотреться, то можно написать лучше. В реализации слишком много ответственности на один маленький гард, и код не читаемый. Поправим это.
Рефакторинг
Раз слишком много ответственности, можно разделить гарды на два, учитывая, что свойство CanActivate
у роута принимает массив гардов.
// admin-page-feature.guard.ts
@Injectable()
export class AdminPageFeatureGuard implements CanActivate {
constructor(
private readonly featureToggleService: FeatureToggleService,
private readonly router: Router
) {}
canActivate(): Observable<boolean | UrlTree> {
return this.featureToggleService.isAdminPageEnabled$.pipe(
map(
isAdminPageEnabled =>
isAdminPageEnabled || this.router.createUrlTree(['/'])
)
);
}
}
// admin.guard.ts
@Injectable()
export class AdminGuard implements CanActivate {
constructor(
private readonly userService: UserService,
private readonly router: Router
) {}
canActivate(): Observable<boolean | UrlTree> {
return this.userService.isAdmin$.pipe(
map(isAdmin => isAdmin || this.router.createUrlTree(['/']))
);
}
}
Теперь стало два гарда, которые можно переиспользовать, и код стал читаемый. Казалось бы все отлично, но на этом моменте я озадачился одним вопросом. Гарды в массиве CanActivate
выполняются последовательно или параллельно?
const routes: Routes = [
{
path: 'admin',
component: AdminPageComponent,
canActivate: [AdminGuard, AdminPageFeatureGuard],
},
];
Дела мои плохи, если гарды выполняются последовательно, так как нет смысла заставлять пользователя ждать каждый гард отдельно.
Я провел исследование и выяснил, что они выполняются параллельно, но с некоторыми нюансами.
Приоритеты гардов
В далекой седьмой версии Angular завезли приоритет для гардов. Переданные в массив для CanActivate
гарды будут выполняться параллельно, но роутер будет ждать, пока более высокий приоритет завершится, чтобы двигаться дальше. Приоритет определяется порядком в массиве СanActivate
.
Разберемся на примере. Есть два гарда HighPriorityGuard
и LowPriorityGuard
. Оба возвращают Observable<boolean | UrlTree>. Мы производим навигацию по роуту, где применяются эти гарды.
canActivate: [HighPriorityGuard, LowPriorityGuard]
Факты о выполнение гардов:
Запускаются параллельно.
Если
LowPriorityGuard
возвращаетUrlTree|false
первым, то роутер все равно будет дожидаться выполненияHighPriorityGuard
перед тем, как начать навигацию.Если
HighPriorityGuard
возвращает первымUrlTree
, то сразу будет произведена навигация наUrlTree
изHighPriorityGuard
.Если
LowPriorityGuard
возвращаетUrlTree
первым, а далееHighPriorityGuard
возвращаетUrlTree
, то навигация произойдет наUrlTree
изHighPriorityGuard
, так как побеждает более высокий приоритет.Если
HighPriorityGuard
возвращаетtrue
первым, роутер будет ждатьLowPriorityGuard
, так как не может производить навигацию, пока не убедится, что все гарды возвращаютtrue
.
Если остались вопросы по приоритетам гардов, пожалуйста, перейдите по этой ссылке, я подготовил небольшое демо, там будет проще разобраться.
Зачем нужны приоритеты
Команда Angular добавила приоритет для гардов, чтобы решить проблему множественных редиректов. Допустим, в приложение есть два гарда — аутентификации и админа. Гард аутентификации, будет отправлять на страницу логина, если пользователь не залогинился. А гард админа, будет отправлять на страницу “Вы не админ”, если у пользователя нет нужной роли. До седьмой версии Angular, навигация выполнялась из гарда, который первый сделает редирект. И выходило так что пользователь, который не залогинен, может попасть на страницу “Вы не админ”, потому что гард админа выполнился быстрее.
Но теперь мы имеем UrlTree
и приоритет гардов. Если гард аутентификации важнее, то ставим его первым в массив, тем самым даем ему более высокий приоритет, тогда роутер будет ждать его выполнения и проводить редирект на UrlTree
из него.
Краткие итоги
Наделяйте гарды только одной ответственностью, тогда их легче читать и переиспользовать. Но помните про ловушку приоритетов. Если у вас первый гард выполняется за секунду, а второй за час, и нет разницы по редиректу, то стоит подумать о порядке в массиве CanActivate/CanActivateChild/CanLoad/CanDeactivate
, чтобы иногда пользователь не ждал один час.
Я написал свое решение для обхода приоритета гардов, потому что меня категорично не устраивало заставлять пользователя ждать, но об этом в следующей статьей.