解析 Angular AuthService:网关、存储、界面层与端口
将臃肿的 Angular AuthService 拆分为网关、信号存储、存储接口和外观层,同时实现单次飞行令牌刷新功能、作用域拦截器以及可测试的守卫。
来源: Clean Architecture in Angular: Refactoring the AuthService with Ports & Adapters
几乎每个 Angular 应用都包含 AuthService,在许多项目中它都是没人愿意触碰的文件。该服务集成了 HTTP 请求、令牌持久化、响应式状态管理、刷新逻辑以及导航功能,因此修改其中任意一部分都意味着需要查看全部代码。本文将介绍如何将该服务拆分为具有单向依赖关系的小模块,并展示新架构带来的两个实际问题:会导致未执行登出操作的刷新失败,以及向第三方 API 泄露令牌的拦截器问题。通过学习,你将掌握可应用于自己认证功能的架构方案,并明确了解在何种情况下无需增加额外文件。
“上帝对象”AuthService 的形成过程
没人会故意设计一个包含300行代码的认证服务。它最初只是一个调用单个端点的login()方法。之后因为需要存储令牌,便出现了一个对应的字段;又因为令牌必须在页面刷新后依然有效,于是加入了localStorage。接着是刷新令牌的实现,随后为了让组件能够响应登录状态,又引入了BehaviorSubject;最后在登出时使用了router.navigate('/login'),因为当时似乎找不到更合适的处理方式。一年半后,这个类开始导入Router、HttpClient和localStorage,而要更改令牌存储策略,则必须理解整个文件的结构。
该起始点的简化版本如下。请注意,在login()函数中,有多少个无关的逻辑都使用了同一个tap操作:更新字段、写入两个存储键、发送状态信息以及进行页面跳转。
@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`);
}
}
这是马丁·福勒所著《企业应用架构模式》中的网关模式。它常被称为仓库,但仓库意味着一种可查询的集合式抽象结构。而围绕远程调用的类型化封装,更准确的称呼应是网关。
其优势在于能够将协议相关知识封装起来。如果登录端点从/auth/login变为/v2/sessions,只需修改这个类即可;应用程序的其他部分无需处理任何URL。
缺失的功能与现有的功能同样重要:没有状态管理、没有localStorage、没有Router,错误处理也仅限于HTTP本身提供的功能。每个方法只是将参数转换为冷的Observable对象,这使得在测试中可以用虚拟对象轻松替换该类。
基础URL是通过注入令牌而非硬编码的方式传入的:
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属性的cookie。由于存储在端口之后,这种迁移只需修改一个适配器,而无需在整个代码库中进行搜索替换;在完成迁移之前,风险仅存在于带有相关注释的某个文件中。第三点具有普遍意义。良好的架构不会把错误的决策变成正确的决策,它能让决策在本地进行、清晰可见,并且易于撤销。
外观层:无所有权的协调机制
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仍然不存在。这个外观层并不负责导航,因为导航属于视图层的功能。登录页面希望跳转到returnUrl,控制面板的退出按钮则希望跳转至/login,而会话过期提示框可能根本不会进行任何导航操作。如果在logout()函数中调用router.navigate(),就会强制所有调用者遵循同一套规则。相反,应由调用该函数的组件自行决定:
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);
}),
);
在 refresh() 方法中进行的同步 throw 操作会在表达式 auth.refresh() 还在计算期间就触发,此时甚至还未调用 .pipe()。因此 catchError 操作符根本无法获取错误信息,进而导致 auth.logout() 也永远不会被执行。而该保护条款原本设计的场景——刷新令牌缺失——恰恰就是用户处于半认证状态且毫无提示的情况。
通过返回 throwError(() => ...) 可以将故障转化为流上的错误通知,从而使拦截器的登出逻辑能够按预期执行。一般来说,那些返回 Observable 的方法应当通过该 Observable 来报告故障,而非直接抛出异常。
这个架构上的教训是:在包含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接口,这正体现了典型的横切关注点特性。这类逻辑不应隶属于某个特定的调用方。如果没有拦截器,每个访问需要认证端点的服务方法都不得不重复编写获取令牌和重试的代码;一旦这30份代码中的某一份在重试逻辑上出现细微错误,就会导致在生产环境中每周出现一次故障,而在开发环境中却完全不会出现。关于Angular中失败请求的更全面处理方式,请参阅Angular中失败请求的处理设计。
现在仔细看看函数顶部的保护条款:
if (!isOurApi(req, baseUrl) || isUnauthenticatedRequest(req)) {
return next(req);
}
这就是注入的 API_BASE_URL 发挥作用的地方。它解决了两个实际问题:
- 令牌泄露到其他域名。那些不进行URL校验的拦截器会将用户的访问令牌附加到每一个外发请求中,包括上传到对象存储、地图瓦片提供商以及分析端点的请求。仅仅因为拦截器过于冒进,就导致敏感凭证被泄露给无关的第三方。
- 使用过期令牌进行刷新请求。如果没有排除列表,刷新请求本身也会附带过期的访问令牌被发送出去。宽松的后端会忽略它;严格的后端则会再次返回401错误,从而导致刷新循环。
一旦基础URL可以注入,这两种情况都是一行代码即可解决的。如果没有基础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' },
];
authGuard设置的returnUrl查询参数无需任何手动操作的ActivatedRoute代码即可传递到登录组件。通过使用provideRouter(routes, withComponentInputBinding()),查询参数会被绑定到对应的信号输入上:
readonly returnUrl = input('/dashboard');
// ...
this.auth.login(username, password).subscribe({
next: () => this.router.navigateByUrl(this.returnUrl()),
error: () => { /* ... */ },
});
该项目中的一个重要细节是:在该保护机制出现之前,store就已经提供了isAuthenticated这个计算型信号,但却没有任何地方读取它。这类未被使用的状态表明设计尚未完成。这并非无害现象,而是应用程序尚未履行的承诺。
分层结构在测试中带来的优势
最明显的优势在于这些测试几乎不需要任何准备工作。无需组件固定装置,无需清理localStorage,也无需手动编写HttpClient的模拟对象。下面的配置提供了Angular的HTTP测试后端,仅需替换两个参数:基础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');
});
单次飞行测试会调用两次refresh(),然后使用http.expectOne,如果发送了多个匹配的请求,则该测试会失败。这样只需几行代码就能验证合并操作的正确性,而且即便一年后有人重新实现了refresh()函数,这一验证依然有效。整个测试框架仅由两个注入的参数构成,而这正是设置这些端口的目的。
这种设计不具备什么特点
明确界定某种模式的边界与为其命名同样重要。
它既不是 Redux 也不是 NgRx。没有动作、还原器、效应函数或派发循环。AuthStore是以带有意图标识的方法封装的状态。如果需要时间回溯调试、跨功能模块的效应协调,或是用于重放的序列化动作日志,那就改用 NgRx;这种封装方式意味着组件不会察觉到切换。
它也不属于完整的清洁架构。这里没有用例类,领域模型也与数据传输格式混在了一起。auth-models.ts中的User类型仅仅是 API 响应的结构。严格的清洁架构会定义独立的类型并在它们之间进行转换。由于当前只有少量字段,这样的映射层带来的成本高于其价值,但一旦 API 结构与领域模型开始出现差异,就可以引入这样的映射层来处理它们。
这并非免费服务。七个文件、多次间接调用以及为一个字符串生成的注入令牌都是实际成本。在双屏幕原型中这样做属于过度设计,而刻意编写一个大型服务才是正确的选择。通常在有两个功能需要相同的会话状态,或者首次需向团队成员解释刷新逻辑时,就会达到盈亏平衡点。
总结
整个设计可归结为一条规则:依赖关系只能单向流动,每个功能模块仅存在于一个文件中。
interceptor → service → { api, store } → models
实际上这意味着:网关无法访问状态信息,存储模块不处理网络通信,外观层将路由任务交给调用方,各组件仅与外观层交互。没有任何一层会逆向穿透整个架构链。
正是这一限制使得从隐式半登出状态触发的同步 throw 操作变成了一次失败的简短测试。它让“哪些请求携带了令牌?”这个问题有了只需一行就能回答的答案。同时,将来若要将存储方式从 localStorage 改为 httpOnly Cookie,也只需修改相应的适配器,而无需在整个代码库中逐一查找。
这些设计模式的名字听起来很宏大:网关、外观层、端口与适配器、责任链、策略执行点。但实际上它们都在表达同一个理念——为每个决策指定一个明确的处理位置,让依赖关系一目了然,其余问题便随之迎刃而解。