Spring Security(五)-- 动手实现一个 IP_Login

在开始这篇文章之前,我们似乎应该思考下为什么需要搞清楚 Spring Security 的内部工作原理?按照第二篇文章中的配置,一个简单的表单认证不就达成了吗?更有甚者,为什么我们不自己写一个表单认证,用过滤器即可完成,大费周章引入 Spring Security,看起来也并没有方便多少。对的,在引入 Spring Security 之前,我们得首先想到,是什么需求让我们引入了 Spring Security,以及为什么是 Spring Security,而不是 shiro 等等其他安全框架。我的理解是有如下几点:

1 在前文的介绍中,Spring Security 支持防止 csrf 攻击,session-fixation protection,支持表单认证,basic 认证,rememberMe… 等等一些特性,有很多是开箱即用的功能,而大多特性都可以通过配置灵活的变更,这是它的强大之处。

2 Spring Security 的兄弟的项目 Spring Security SSO,OAuth2 等支持了多种协议,而这些都是基于 Spring Security 的,方便了项目的扩展。

3 SpringBoot 的支持,更加保证了 Spring Security 的开箱即用。

4 为什么需要理解其内部工作原理? 一个有自我追求的程序员都不会满足于浅尝辄止,如果一个开源技术在我们的日常工作中十分常用,那么我偏向于阅读其源码,这样可以让我们即使排查不期而至的问题,也方便日后需求扩展。

5 Spring 及其子项目的官方文档是我见过的最良心的文档!~~ 相比较于 Apache 的部分文档 ~~

这一节,为了对之前分析的 Spring Security 源码和组件有一个清晰的认识,介绍一个使用 IP 完成登录的简单 demo。

查看更多

分享到

Spring Security(四)-- 核心过滤器源码分析

[TOC]

前面的部分,我们关注了 Spring Security 是如何完成认证工作的,但是另外一部分核心的内容:过滤器,一直没有提到,我们已经知道 Spring Security 使用了 springSecurityFillterChian 作为了安全过滤的入口,这一节主要分析一下这个过滤器链都包含了哪些关键的过滤器,并且各自的使命是什么。

4 过滤器详解

4.1 核心过滤器概述

由于过滤器链路中的过滤较多,即使是 Spring Security 的官方文档中也并未对所有的过滤器进行介绍,在之前,《Spring Security(二)–Guides》入门指南中我们配置了一个表单登录的 demo,以此为例,来看看这过程中 Spring Security 都帮我们自动配置了哪些过滤器。

1
2
3
4
5
6
7
8
9
10
11
12
Creating filter chain: o.s.s.web.util.matcher.AnyRequestMatcher@1, 
[o.s.s.web.context.SecurityContextPersistenceFilter@8851ce1,
o.s.s.web.header.HeaderWriterFilter@6a472566, o.s.s.web.csrf.CsrfFilter@61cd1c71,
o.s.s.web.authentication.logout.LogoutFilter@5e1d03d7,
o.s.s.web.authentication.UsernamePasswordAuthenticationFilter@122d6c22,
o.s.s.web.savedrequest.RequestCacheAwareFilter@5ef6fd7f,
o.s.s.web.servletapi.SecurityContextHolderAwareRequestFilter@4beaf6bd,
o.s.s.web.authentication.AnonymousAuthenticationFilter@6edcad64,
o.s.s.web.session.SessionManagementFilter@5e65afb6,
o.s.s.web.access.ExceptionTranslationFilter@5b9396d3,
o.s.s.web.access.intercept.FilterSecurityInterceptor@3c5dbdf8
]

上述的 log 信息是我从 springboot 启动的日志中 CV 所得,spring security 的过滤器日志有一个特点:log 打印顺序与实际配置顺序符合,也就意味着 SecurityContextPersistenceFilter 是整个过滤器链的第一个过滤器,而 FilterSecurityInterceptor 则是末置的过滤器。另外通过观察过滤器的名称,和所在的包名,可以大致地分析出他们各自的作用,如 UsernamePasswordAuthenticationFilter 明显便是与使用用户名和密码登录相关的过滤器,而 FilterSecurityInterceptor 我们似乎看不出它的作用,但是其位于 web.access 包下,大致可以分析出他与访问限制相关。第四篇文章主要就是介绍这些常用的过滤器,对其中关键的过滤器进行一些源码分析。先大致介绍下每个过滤器的作用:

  • SecurityContextPersistenceFilter 两个主要职责:请求来临时,创建 SecurityContext 安全上下文信息,请求结束时清空 SecurityContextHolder
  • HeaderWriterFilter (文档中并未介绍,非核心过滤器) 用来给 http 响应添加一些 Header, 比如 X-Frame-Options, X-XSS-Protection*,X-Content-Type-Options.
  • CsrfFilter 在 spring4 这个版本中被默认开启的一个过滤器,用于防止 csrf 攻击,了解前后端分离的人一定不会对这个攻击方式感到陌生,前后端使用 json 交互需要注意的一个问题。
  • LogoutFilter 顾名思义,处理注销的过滤器
  • UsernamePasswordAuthenticationFilter 这个会重点分析,表单提交了 username 和 password,被封装成 token 进行一系列的认证,便是主要通过这个过滤器完成的,在表单认证的方法中,这是最最关键的过滤器。
  • RequestCacheAwareFilter (文档中并未介绍,非核心过滤器) 内部维护了一个 RequestCache,用于缓存 request 请求
  • SecurityContextHolderAwareRequestFilter 此过滤器对 ServletRequest 进行了一次包装,使得 request 具有更加丰富的 API
  • AnonymousAuthenticationFilter 匿名身份过滤器,这个过滤器个人认为很重要,需要将它与 UsernamePasswordAuthenticationFilter 放在一起比较理解,spring security 为了兼容未登录的访问,也走了一套认证流程,只不过是一个匿名的身份。
  • SessionManagementFilter 和 session 相关的过滤器,内部维护了一个 SessionAuthenticationStrategy,两者组合使用,常用来防止 session-fixation protection attack,以及限制同一用户开启多个会话的数量
  • ExceptionTranslationFilter 直译成异常翻译过滤器,还是比较形象的,这个过滤器本身不处理异常,而是将认证过程中出现的异常交给内部维护的一些类去处理,具体是那些类下面详细介绍
  • FilterSecurityInterceptor 这个过滤器决定了访问特定路径应该具备的权限,访问的用户的角色,权限是什么?访问的路径需要什么样的角色和权限?这些判断和处理都是由该类进行的。

其中加粗的过滤器可以被认为是 Spring Security 的核心过滤器,将在下面,一个过滤器对应一个小节来讲解。

4.2 SecurityContextPersistenceFilter

试想一下,如果我们不使用 Spring Security,如果保存用户信息呢,大多数情况下会考虑使用 Session 对吧?在 Spring Security 中也是如此,用户在登录过一次之后,后续的访问便是通过 sessionId 来识别,从而认为用户已经被认证。具体在何处存放用户信息,便是第一篇文章中提到的 SecurityContextHolder;认证相关的信息是如何被存放到其中的,便是通过 SecurityContextPersistenceFilter。在 4.1 概述中也提到了,SecurityContextPersistenceFilter 的两个主要作用便是请求来临时,创建 SecurityContext 安全上下文信息和请求结束时清空 SecurityContextHolder。顺带提一下:微服务的一个设计理念需要实现服务通信的无状态,而 http 协议中的无状态意味着不允许存在 session,这可以通过 setAllowSessionCreation(false) 实现,这并不意味着 SecurityContextPersistenceFilter 变得无用,因为它还需要负责清除用户信息。在 Spring Security 中,虽然安全上下文信息被存储于 Session 中,但我们在实际使用中不应该直接操作 Session,而应当使用 SecurityContextHolder。

源码分析

org.springframework.security.web.context.SecurityContextPersistenceFilter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class SecurityContextPersistenceFilter extends GenericFilterBean {

static final String FILTER_APPLIED = "__spring_security_scpf_applied";
// 安全上下文存储的仓库
private SecurityContextRepository repo;

public SecurityContextPersistenceFilter() {
//HttpSessionSecurityContextRepository 是 SecurityContextRepository 接口的一个实现类
// 使用 HttpSession 来存储 SecurityContext
this(new HttpSessionSecurityContextRepository());
}

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;

if (request.getAttribute(FILTER_APPLIED) != null) {
// ensure that filter is only applied once per request
chain.doFilter(request, response);
return;
}
request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
// 包装 request,response
HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request,
response);
// 从 Session 中获取安全上下文信息
SecurityContext contextBeforeChainExecution = repo.loadContext(holder);
try {
// 请求开始时,设置安全上下文信息,这样就避免了用户直接从 Session 中获取安全上下文信息
SecurityContextHolder.setContext(contextBeforeChainExecution);
chain.doFilter(holder.getRequest(), holder.getResponse());
}
finally {
// 请求结束后,清空安全上下文信息
SecurityContext contextAfterChainExecution = SecurityContextHolder
.getContext();
SecurityContextHolder.clearContext();
repo.saveContext(contextAfterChainExecution, holder.getRequest(),
holder.getResponse());
request.removeAttribute(FILTER_APPLIED);
if (debug) {
logger.debug("SecurityContextHolder now cleared, as request processing completed");
}
}
}

}

过滤器一般负责核心的处理流程,而具体的业务实现,通常交给其中聚合的其他实体类,这在 Filter 的设计中很常见,同时也符合职责分离模式。例如存储安全上下文和读取安全上下文的工作完全委托给了 HttpSessionSecurityContextRepository 去处理,而这个类中也有几个方法可以稍微解读下,方便我们理解内部的工作流程

org.springframework.security.web.context.HttpSessionSecurityContextRepository

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public class HttpSessionSecurityContextRepository implements SecurityContextRepository {
// 'SPRING_SECURITY_CONTEXT' 是安全上下文默认存储在 Session 中的键值
public static final String SPRING_SECURITY_CONTEXT_KEY = "SPRING_SECURITY_CONTEXT";
...
private final Object contextObject = SecurityContextHolder.createEmptyContext();
private boolean allowSessionCreation = true;
private boolean disableUrlRewriting = false;
private String springSecurityContextKey = SPRING_SECURITY_CONTEXT_KEY;

private AuthenticationTrustResolver trustResolver = new AuthenticationTrustResolverImpl();

// 从当前 request 中取出安全上下文,如果 session 为空,则会返回一个新的安全上下文
public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
HttpServletRequest request = requestResponseHolder.getRequest();
HttpServletResponse response = requestResponseHolder.getResponse();
HttpSession httpSession = request.getSession(false);
SecurityContext context = readSecurityContextFromSession(httpSession);
if (context == null) {
context = generateNewContext();
}
...
return context;
}

...

public boolean containsContext(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return false;
}
return session.getAttribute(springSecurityContextKey) != null;
}

private SecurityContext readSecurityContextFromSession(HttpSession httpSession) {
if (httpSession == null) {
return null;
}
...
// Session 存在的情况下,尝试获取其中的 SecurityContext
Object contextFromSession = httpSession.getAttribute(springSecurityContextKey);
if (contextFromSession == null) {
return null;
}
...
return (SecurityContext) contextFromSession;
}

// 初次请求时创建一个新的 SecurityContext 实例
protected SecurityContext generateNewContext() {
return SecurityContextHolder.createEmptyContext();
}

}

SecurityContextPersistenceFilter 和 HttpSessionSecurityContextRepository 配合使用,构成了 Spring Security 整个调用链路的入口,为什么将它放在最开始的地方也是显而易见的,后续的过滤器中大概率会依赖 Session 信息和安全上下文信息。

4.3 UsernamePasswordAuthenticationFilter

表单认证是最常用的一个认证方式,一个最直观的业务场景便是允许用户在表单中输入用户名和密码进行登录,而这背后的 UsernamePasswordAuthenticationFilter,在整个 Spring Security 的认证体系中则扮演着至关重要的角色。

https://kirito.iocoder.cn/2011121410543010.jpg

上述的时序图,可以看出 UsernamePasswordAuthenticationFilter 主要肩负起了调用身份认证器,校验身份的作用,至于认证的细节,在前面几章花了很大篇幅进行了介绍,到这里,其实 Spring Security 的基本流程就已经走通了。

源码分析

org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter#attemptAuthentication

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
// 获取表单中的用户名和密码
String username = obtainUsername(request);
String password = obtainPassword(request);
...
username = username.trim();
// 组装成 username+password 形式的 token
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
username, password);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
// 交给内部的 AuthenticationManager 去认证,并返回认证信息
return this.getAuthenticationManager().authenticate(authRequest);
}

UsernamePasswordAuthenticationFilter 本身的代码只包含了上述这么一个方法,非常简略,而在其父类 AbstractAuthenticationProcessingFilter 中包含了大量的细节,值得我们分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean
implements ApplicationEventPublisherAware, MessageSourceAware {
// 包含了一个身份认证器
private AuthenticationManager authenticationManager;
// 用于实现 remeberMe
private RememberMeServices rememberMeServices = new NullRememberMeServices();
private RequestMatcher requiresAuthenticationRequestMatcher;
// 这两个 Handler 很关键,分别代表了认证成功和失败相应的处理器
private AuthenticationSuccessHandler successHandler = new SavedRequestAwareAuthenticationSuccessHandler();
private AuthenticationFailureHandler failureHandler = new SimpleUrlAuthenticationFailureHandler();

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {

HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
...
Authentication authResult;
try {
// 此处实际上就是调用 UsernamePasswordAuthenticationFilter 的 attemptAuthentication 方法
authResult = attemptAuthentication(request, response);
if (authResult == null) {
// 子类未完成认证,立刻返回
return;
}
sessionStrategy.onAuthentication(authResult, request, response);
}
// 在认证过程中可以直接抛出异常,在过滤器中,就像此处一样,进行捕获
catch (InternalAuthenticationServiceException failed) {
// 内部服务异常
unsuccessfulAuthentication(request, response, failed);
return;
}
catch (AuthenticationException failed) {
// 认证失败
unsuccessfulAuthentication(request, response, failed);
return;
}
// 认证成功
if (continueChainBeforeSuccessfulAuthentication) {
chain.doFilter(request, response);
}
// 注意,认证成功后过滤器把 authResult 结果也传递给了成功处理器
successfulAuthentication(request, response, chain, authResult);
}

}

整个流程理解起来也并不难,主要就是内部调用了 authenticationManager 完成认证,根据认证结果执行 successfulAuthentication 或者 unsuccessfulAuthentication,无论成功失败,一般的实现都是转发或者重定向等处理,不再细究 AuthenticationSuccessHandler 和 AuthenticationFailureHandler,有兴趣的朋友,可以去看看两者的实现类。

4.4 AnonymousAuthenticationFilter

匿名认证过滤器,可能有人会想:匿名了还有身份?我自己对于 Anonymous 匿名身份的理解是 Spirng Security 为了整体逻辑的统一性,即使是未通过认证的用户,也给予了一个匿名身份。而 AnonymousAuthenticationFilter 该过滤器的位置也是非常的科学的,它位于常用的身份认证过滤器(如 UsernamePasswordAuthenticationFilterBasicAuthenticationFilterRememberMeAuthenticationFilter)之后,意味着只有在上述身份过滤器执行完毕后,SecurityContext 依旧没有用户信息,AnonymousAuthenticationFilter 该过滤器才会有意义 —- 基于用户一个匿名身份。

源码分析

org.springframework.security.web.authentication.AnonymousAuthenticationFilter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public class AnonymousAuthenticationFilter extends GenericFilterBean implements
InitializingBean {

private AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource = new WebAuthenticationDetailsSource();
private String key;
private Object principal;
private List<GrantedAuthority> authorities;


// 自动创建一个 "anonymousUser" 的匿名用户, 其具有 ANONYMOUS 角色
public AnonymousAuthenticationFilter(String key) {
this(key, "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
}

/**
*
* @param key key 用来识别该过滤器创建的身份
* @param principal principal 代表匿名用户的身份
* @param authorities authorities 代表匿名用户的权限集合
*/
public AnonymousAuthenticationFilter(String key, Object principal,
List<GrantedAuthority> authorities) {
Assert.hasLength(key, "key cannot be null or empty");
Assert.notNull(principal, "Anonymous authentication principal must be set");
Assert.notNull(authorities, "Anonymous authorities must be set");
this.key = key;
this.principal = principal;
this.authorities = authorities;
}

...

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
// 过滤器链都执行到匿名认证过滤器这儿了还没有身份信息,塞一个匿名身份进去
if (SecurityContextHolder.getContext().getAuthentication()== null) {
SecurityContextHolder.getContext().setAuthentication(
createAuthentication((HttpServletRequest) req));
}
chain.doFilter(req, res);
}

protected Authentication createAuthentication(HttpServletRequest request) {
// 创建一个 AnonymousAuthenticationToken
AnonymousAuthenticationToken auth = new AnonymousAuthenticationToken(key,
principal, authorities);
auth.setDetails(authenticationDetailsSource.buildDetails(request));

return auth;
}
...
}

其实对比 AnonymousAuthenticationFilter 和 UsernamePasswordAuthenticationFilter 就可以发现一些门道了,UsernamePasswordAuthenticationToken 对应 AnonymousAuthenticationToken,他们都是 Authentication 的实现类,而 Authentication 则是被 SecurityContextHolder(SecurityContext) 持有的,一切都被串联在了一起。

4.5 ExceptionTranslationFilter

ExceptionTranslationFilter 异常转换过滤器位于整个 springSecurityFilterChain 的后方,用来转换整个链路中出现的异常,将其转化,顾名思义,转化以意味本身并不处理。一般其只处理两大类异常:AccessDeniedException 访问异常和 AuthenticationException 认证异常。

这个过滤器非常重要,因为它将 Java 中的异常和 HTTP 的响应连接在了一起,这样在处理异常时,我们不用考虑密码错误该跳到什么页面,账号锁定该如何,只需要关注自己的业务逻辑,抛出相应的异常便可。如果该过滤器检测到 AuthenticationException,则将会交给内部的 AuthenticationEntryPoint 去处理,如果检测到 AccessDeniedException,需要先判断当前用户是不是匿名用户,如果是匿名访问,则和前面一样运行 AuthenticationEntryPoint,否则会委托给 AccessDeniedHandler 去处理,而 AccessDeniedHandler 的默认实现,是 AccessDeniedHandlerImpl。所以 ExceptionTranslationFilter 内部的 AuthenticationEntryPoint 是至关重要的,顾名思义:认证的入口点。

源码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class ExceptionTranslationFilter extends GenericFilterBean {
// 处理异常转换的核心方法
private void handleSpringSecurityException(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, RuntimeException exception)
throws IOException, ServletException {
if (exception instanceof AuthenticationException) {
// 重定向到登录端点
sendStartAuthentication(request, response, chain,
(AuthenticationException) exception);
}
else if (exception instanceof AccessDeniedException) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authenticationTrustResolver.isAnonymous(authentication) || authenticationTrustResolver.isRememberMe(authentication)) {
// 重定向到登录端点
sendStartAuthentication(
request,
response,
chain,
new InsufficientAuthenticationException(
"Full authentication is required to access this resource"));
}
else {
// 交给 accessDeniedHandler 处理
accessDeniedHandler.handle(request, response,
(AccessDeniedException) exception);
}
}
}
}

剩下的便是要搞懂 AuthenticationEntryPoint 和 AccessDeniedHandler 就可以了。

AuthenticationEntryPoint

选择了几个常用的登录端点,以其中第一个为例来介绍,看名字就能猜到是认证失败之后,让用户跳转到登录页面。还记得我们一开始怎么配置表单登录页面的吗?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/", "/home").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()//FormLoginConfigurer
.loginPage("/login")
.permitAll()
.and()
.logout()
.permitAll();
}
}

我们顺着 formLogin 返回的 FormLoginConfigurer 往下找,看看能发现什么,最终在 FormLoginConfigurer 的父类 AbstractAuthenticationFilterConfigurer 中有了不小的收获:

1
2
3
4
5
6
7
8
public abstract class AbstractAuthenticationFilterConfigurer extends ...{
...
//formLogin 不出所料配置了 AuthenticationEntryPoint
private LoginUrlAuthenticationEntryPoint authenticationEntryPoint;
// 认证失败的处理器
private AuthenticationFailureHandler failureHandler;
...
}

具体如何配置的就不看了,我们得出了结论,formLogin() 配置了之后最起码做了两件事,其一,为 UsernamePasswordAuthenticationFilter 设置了相关的配置,其二配置了 AuthenticationEntryPoint。

登录端点还有 Http401AuthenticationEntryPoint,Http403ForbiddenEntryPoint 这些都是很简单的实现,有时候我们访问受限页面,又没有配置登录,就看到了一个空荡荡的默认错误页面,上面显示着 401,403,就是这两个入口起了作用。

还剩下一个 AccessDeniedHandler 访问决策器未被讲解,简单提一下:AccessDeniedHandlerImpl 这个默认实现类会根据 errorPage 和状态码来判断,最终决定跳转的页面

org.springframework.security.web.access.AccessDeniedHandlerImpl#handle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException,
ServletException {
if (!response.isCommitted()) {
if (errorPage != null) {
// Put exception into request scope (perhaps of use to a view)
request.setAttribute(WebAttributes.ACCESS_DENIED_403,
accessDeniedException);
// Set the 403 status code.
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
// forward to error page.
RequestDispatcher dispatcher = request.getRequestDispatcher(errorPage);
dispatcher.forward(request, response);
}
else {
response.sendError(HttpServletResponse.SC_FORBIDDEN,
accessDeniedException.getMessage());
}
}
}

4.6 FilterSecurityInterceptor

想想整个认证安全控制流程还缺了什么?我们已经有了认证,有了请求的封装,有了 Session 的关联… 还缺一个:由什么控制哪些资源是受限的,这些受限的资源需要什么权限,需要什么角色… 这一切和访问控制相关的操作,都是由 FilterSecurityInterceptor 完成的。

FilterSecurityInterceptor 的工作流程用笔者的理解可以理解如下:FilterSecurityInterceptor 从 SecurityContextHolder 中获取 Authentication 对象,然后比对用户拥有的权限和资源所需的权限。前者可以通过 Authentication 对象直接获得,而后者则需要引入我们之前一直未提到过的两个类:SecurityMetadataSource,AccessDecisionManager。理解清楚决策管理器的整个创建流程和 SecurityMetadataSource 的作用需要花很大一笔功夫,这里,暂时只介绍其大概的作用。

在 JavaConfig 的配置中,我们通常如下配置路径的访问控制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/resources/**", "/signup", "/about").permitAll()
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/db/**").access("hasRole('ADMIN') and hasRole('DBA')")
.anyRequest().authenticated()
.withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
public <O extends FilterSecurityInterceptor> O postProcess(
O fsi) {
fsi.setPublishAuthorizationSuccess(true);
return fsi;
}
});
}

在 ObjectPostProcessor 的泛型中看到了 FilterSecurityInterceptor,以笔者的经验,目前并没有太多机会需要修改 FilterSecurityInterceptor 的配置。

总结

本篇文章在介绍过滤器时,顺便进行了一些源码的分析,目的是方便理解整个 Spring Security 的工作流。伴随着整个过滤器链的介绍,安全框架的轮廓应该已经浮出水面了,下面的章节,主要打算通过自定义一些需求,再次分析其他组件的源码,学习应该如何改造 Spring Security,为我们所用。

欢迎关注我的微信公众号:「Kirito 的技术分享」,关于文章的任何疑问都会得到回复,带来更多 Java 相关的技术分享。

关注微信公众号

分享到

警惕不规范的变量命名

就在最近,项目组开始强调开发规范了,今天分享一个变量名命名不规范的小案例,强调一下规范的重要性。例子虽小,但却比较有启发意义。

Boolean 变量名命名规范

16 年底,阿里公开了《Java 开发规范手册》,其中有一条便是“布尔类型不能以 is 为前缀”。规范中没有举出例子,但是给出了原因:会导致部分序列化框架的无法解析。

看看错误的示范,会导致什么问题,以 Spring 中的 jdbcTemplate 来进行实验。

定义实体类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Entity
public class Bar {
@Id
@GeneratedValue
private Integer id;
private Boolean isSuccess;// 注意这是错误的命名
private boolean isSend;// 注意这是错误的命名
public Boolean getSuccess() {
return isSuccess;
}
public void setSuccess(Boolean success) {
isSuccess = success;
}
public boolean isSend() {
return isSend;
}
public void setSend(boolean send) {
isSend = send;
}
}

其中,isSuccess 使用的是包装类型 Boolean,而 isSend 使用的是原生类型 boolean,而 getter,setter 方法是使用 Intellij IDEA 自动生成的,布尔类型生成 getter,setter 方法时略微特殊,比如原生类型的 getter 方式是以 is 开头的,他们略微有点区别,注意区分。生成 getter,setter 方法之后,其实已经有点奇怪了,不急,继续下面的实验。

在数据库中,isSuccess 被映射了 is_success,isSend 被映射成了 is_send,这符合我们的预期。并且为了后续的实验,我们事先准备一条记录,用于后续的查询,在 mysql 的方言中,布尔类型被默认自动映射成 byte,1 代表 ture,0 代表 false。

id is_success is_send
1 1 1

使用 JdbcTemplate 查询

1
2
3
4
5
public void test(String id) {
RowMapper<Bar> barRowMapper = new BeanPropertyRowMapper<Bar>(Bar.class);
Bar bar = jdbcTemplate.queryForObject("select * from bar where id = ?", new Object[]{id}, barRowMapper);
System.out.println(bar);
}

JdbcTemplate 提供了 BeanPropertyRowMapper 完成数据库到实体类的映射,事先我重写了 Bar 类的 toString 方法,调用 test(1) 看看是否能成功映射。结果如下:

1
Bar{id=1, isSuccess=null, isSend=false}

数据库中是实际存在这样的字段,并且值都是 true,而使用 JdbcTemplate,却查询不到这样的问题,这边是不遵循规范导致的问题。

相信这个例子可以让大家更加加深映像,特别是在维护老旧代码时,如果发现有 is 开头的 boolean 值,需要额外地注意。

包装类型与原生类型

在回顾一下上述的 demo,原生类型和包装类型都没有封装成功,isSuccess 得到了一个 null 值,isSend 得到了一个 false 值。后者足够引起我们的警惕,如果说前者会引起一个 NullPointerExcepiton 导致程序异常,还可以引起开发者的注意,而后者很有可能一直作为一个隐藏的 bug,不被人所察觉,因为 boolean 的默认值为 false。

在类变量中,也普遍提倡使用包装类型,而原生类型的不足之处是很明显的。以 Integer num; 字段为例,num=null 代表的含义是 num 字段未被保存,未定义;而 num=0 代表的含义是明确的,数量为 0。原生类型的表达能力有限。所以提倡在局部作用域的计算中使用原生类型,而在类变量中使用包装类型。

JavaBean 规范

如今的微服务的时代,都是在聊架构,聊容器编排,竟然还有人聊 JavaBean,但既然说到了规范,顺带提下。

先来做个选择题,以下选项中符合 JavaBean 命名规范的有哪些?:

1
2
3
4
A : ebook
B : eBook
C : Ebook
D : EBook

.

.

.

.

正确答案是:A,D

怎么样,符合你的预想吗?JavaBean 规范并不是像很多人想的那样,首字母小写,之后的每一个单词首字母大写这样的驼峰命名法。正确的命名规范应该是:要么前两个字母都是小写,要么前两个字母都是大写。因为英文单词中有 URL,USA 这样固定形式的大写词汇,所以才有了这样的规范。特别警惕 B 那种形式,一些诸如 sNo,eBook,eMail,cId 这样的命名,都是不规范的。

由此引申出了 getter,setter 命名的规范,除了第一节中 Boolean 类型的特例之外,网上还有不上文章,强调了这样的概念:eBook 对应的 getter,setter 应当为 geteBook(),seteBook(),即当类变量的首字母是小写,而第二个字母是大写时,生成的 getter,setter 应当是(get/set)+ 类变量名。但上面已经介绍过了,eBook 这样的变量命名本身就是不规范的,在不规范的变量命名下强调规范的 getter,setter 命名,出发点就错了。有兴趣的朋友可以在 eclipse,intellij idea 中试试,这几种规范,不规范的变量命名,各自对应的 getter,setter 方法是如何的。另外需要知晓一点,IDE 提供的自动生成 getter,setter 的机制,以及 lombok 这类框架的机制,都是由默认的设置,在与其他反射框架配合使用时,只有双方都遵循规范,才能够配合使用,而不能笃信框架。这一点上,有部分国产的框架做的并不是很好。

最后说一个和 JavaBean 相关的取值规范,在 jsp 的 c 标签,freemarker 一类的模板语法,以及一些 el 表达式中,${student.name} 并不是取的 student 的 name 字段,而是调用了 student 的 getName 方法,这也应当被注意,student.name 如何找到对应的 getter 方法,需要解决上一段中提到的同样的问题,建议不确定的地方多测试,尽量采取稳妥的写法。

可能有人会觉得这样的介绍类似于“茴”字有几种写法,但笔者认为恰恰是这些小的规范,容易被人忽视,才更加需要被注意。

分享到

Spring Security(二)--Guides

上一篇文章《Spring Security(一)–Architecture Overview》,我们介绍了 Spring Security 的基础架构,这一节我们通过 Spring 官方给出的一个 guides 例子,来了解 Spring Security 是如何保护我们的应用的,之后会对进行一个解读。

[TOC]

2 Spring Security Guides

2.1 引入依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
</dependencies>

由于我们集成了 springboot,所以不需要显示的引入 Spring Security 文档中描述 core,config 依赖,只需要引入 spring-boot-starter-security 即可。

查看更多

分享到

Spring Security(三)-- 核心配置解读

上一篇文章《Spring Security(二)–Guides》,通过 Spring Security 的配置项了解了 Spring Security 是如何保护我们的应用的,本篇文章对上一次的配置做一个分析。

[TOC]

3 核心配置解读

3.1 功能介绍

这是 Spring Security 入门指南中的配置项:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/", "/home").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.permitAll()
.and()
.logout()
.permitAll();
}

@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth
.inMemoryAuthentication()
.withUser("admin").password("admin").roles("USER");
}
}

当配置了上述的 javaconfig 之后,我们的应用便具备了如下的功能:

  • 除了“/”,”/home”(首页),”/login”(登录),”/logout”(注销), 之外,其他路径都需要认证。
  • 指定“/login”该路径为登录页面,当未认证的用户尝试访问任何受保护的资源时,都会跳转到“/login”。
  • 默认指定“/logout”为注销页面
  • 配置一个内存中的用户认证器,使用 admin/admin 作为用户名和密码,具有 USER 角色

3.2 @EnableWebSecurity

我们自己定义的配置类 WebSecurityConfig 加上了 @EnableWebSecurity 注解,同时继承了 WebSecurityConfigurerAdapter。你可能会在想谁的作用大一点,毫无疑问 @EnableWebSecurity 起到决定性的配置作用,它其实是个组合注解。

1
2
3
4
5
6
7
@Import({ WebSecurityConfiguration.class, // <2>
SpringWebMvcImportSelector.class }) // <1>
@EnableGlobalAuthentication // <3>
@Configuration
public @interface EnableWebSecurity {
boolean debug() default false;
}

@Import 是 springboot 提供的用于引入外部的配置的注解,可以理解为:@EnableWebSecurity 注解激活了 @Import 注解中包含的配置类。

<1> SpringWebMvcImportSelector 的作用是判断当前的环境是否包含 springmvc,因为 spring security 可以在非 spring 环境下使用,为了避免 DispatcherServlet 的重复配置,所以使用了这个注解来区分。

<2> WebSecurityConfiguration 顾名思义,是用来配置 web 安全的,下面的小节会详细介绍。

<3> @EnableGlobalAuthentication 注解的源码如下:

1
2
3
4
@Import(AuthenticationConfiguration.class)
@Configuration
public @interface EnableGlobalAuthentication {
}

注意点同样在 @Import 之中,它实际上激活了 AuthenticationConfiguration 这样的一个配置类,用来配置认证相关的核心类。

也就是说:@EnableWebSecurity 完成的工作便是加载了 WebSecurityConfiguration,AuthenticationConfiguration 这两个核心配置类,也就此将 spring security 的职责划分为了配置安全信息,配置认证信息两部分。

WebSecurityConfiguration

在这个配置类中,有一个非常重要的 Bean 被注册了。

1
2
3
4
5
6
7
8
9
10
@Configuration
public class WebSecurityConfiguration {

//DEFAULT_FILTER_NAME = "springSecurityFilterChain"
@Bean(name = AbstractSecurityWebApplicationInitializer.DEFAULT_FILTER_NAME)
public Filter springSecurityFilterChain() throws Exception {
...
}

}

在未使用 springboot 之前,大多数人都应该对“springSecurityFilterChain”这个名词不会陌生,他是 spring security 的核心过滤器,是整个认证的入口。在曾经的 XML 配置中,想要启用 spring security,需要在 web.xml 中进行如下配置:

1
2
3
4
5
6
7
8
9
10
<!-- Spring Security -->
<filter>
<filter-name>springSecurityFilterChain</filter-name>
<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>

<filter-mapping>
<filter-name>springSecurityFilterChain</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>

而在 springboot 集成之后,这样的 XML 被 java 配置取代。WebSecurityConfiguration 中完成了声明 springSecurityFilterChain 的作用,并且最终交给 DelegatingFilterProxy 这个代理类,负责拦截请求(注意 DelegatingFilterProxy 这个类不是 spring security 包中的,而是存在于 web 包中,spring 使用了代理模式来实现安全过滤的解耦)。

AuthenticationConfiguration

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
@Import(ObjectPostProcessorConfiguration.class)
public class AuthenticationConfiguration {

@Bean
public AuthenticationManagerBuilder authenticationManagerBuilder(
ObjectPostProcessor<Object> objectPostProcessor) {
return new AuthenticationManagerBuilder(objectPostProcessor);
}

public AuthenticationManager getAuthenticationManager() throws Exception {
...
}

}

AuthenticationConfiguration 的主要任务,便是负责生成全局的身份认证管理者 AuthenticationManager。还记得在《Spring Security(一)–Architecture Overview》中,介绍了 Spring Security 的认证体系,AuthenticationManager 便是最核心的身份认证管理器。

3.3 WebSecurityConfigurerAdapter

适配器模式在 spring 中被广泛的使用,在配置中使用 Adapter 的好处便是,我们可以选择性的配置想要修改的那一部分配置,而不用覆盖其他不相关的配置。WebSecurityConfigurerAdapter 中我们可以选择自己想要修改的内容,来进行重写,而其提供了三个 configure 重载方法,是我们主要关心的:

WebSecurityConfigurerAdapter 中的 configure

由参数就可以知道,分别是对 AuthenticationManagerBuilder,WebSecurity,HttpSecurity 进行个性化的配置。

HttpSecurity 常用配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Configuration
@EnableWebSecurity
public class CustomWebSecurityConfig extends WebSecurityConfigurerAdapter {

@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/resources/**", "/signup", "/about").permitAll()
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/db/**").access("hasRole('ADMIN') and hasRole('DBA')")
.anyRequest().authenticated()
.and()
.formLogin()
.usernameParameter("username")
.passwordParameter("password")
.failureForwardUrl("/login?error")
.loginPage("/login")
.permitAll()
.and()
.logout()
.logoutUrl("/logout")
.logoutSuccessUrl("/index")
.permitAll()
.and()
.httpBasic()
.disable();
}
}

上述是一个使用 Java Configuration 配置 HttpSecurity 的典型配置,其中 http 作为根开始配置,每一个 and()对应了一个模块的配置(等同于 xml 配置中的结束标签),并且 and() 返回了 HttpSecurity 本身,于是可以连续进行配置。他们配置的含义也非常容易通过变量本身来推测,

  • authorizeRequests() 配置路径拦截,表明路径访问所对应的权限,角色,认证信息。
  • formLogin() 对应表单认证相关的配置
  • logout() 对应了注销相关的配置
  • httpBasic() 可以配置 basic 登录
  • etc

他们分别代表了 http 请求相关的安全配置,这些配置项无一例外的返回了 Configurer 类,而所有的 http 相关配置可以通过查看 HttpSecurity 的主要方法得知:

https://kirito.iocoder.cn/QQ%E5%9B%BE%E7%89%8720170924223252.png

需要对 http 协议有一定的了解才能完全掌握所有的配置,不过,springboot 和 spring security 的自动配置已经足够使用了。其中每一项 Configurer(e.g.FormLoginConfigurer,CsrfConfigurer)都是 HttpConfigurer 的细化配置项。

WebSecurityBuilder

1
2
3
4
5
6
7
8
9
10
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
public void configure(WebSecurity web) throws Exception {
web
.ignoring()
.antMatchers("/resources/**");
}
}

以笔者的经验,这个配置中并不会出现太多的配置信息。

AuthenticationManagerBuilder

1
2
3
4
5
6
7
8
9
10
11
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.inMemoryAuthentication()
.withUser("admin").password("admin").roles("USER");
}
}

想要在 WebSecurityConfigurerAdapter 中进行认证相关的配置,可以使用 configure(AuthenticationManagerBuilder auth) 暴露一个 AuthenticationManager 的建造器:AuthenticationManagerBuilder 。如上所示,我们便完成了内存中用户的配置。

细心的朋友会发现,在前面的文章中我们配置内存中的用户时,似乎不是这么配置的,而是:

1
2
3
4
5
6
7
8
9
10
11
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth
.inMemoryAuthentication()
.withUser("admin").password("admin").roles("USER");
}
}

如果你的应用只有唯一一个 WebSecurityConfigurerAdapter,那么他们之间的差距可以被忽略,从方法名可以看出两者的区别:使用 @Autowired 注入的 AuthenticationManagerBuilder 是全局的身份认证器,作用域可以跨越多个 WebSecurityConfigurerAdapter,以及影响到基于 Method 的安全控制;而 protected configure() 的方式则类似于一个匿名内部类,它的作用域局限于一个 WebSecurityConfigurerAdapter 内部。关于这一点的区别,可以参考我曾经提出的 issuespring-security#issues4571。官方文档中,也给出了配置多个 WebSecurityConfigurerAdapter 的场景以及 demo,将在该系列的后续文章中解读。

欢迎关注我的微信公众号:「Kirito 的技术分享」,关于文章的任何疑问都会得到回复,带来更多 Java 相关的技术分享。

关注微信公众号

分享到

Spring Security(一)--Architecture Overview

一直以来我都想写一写 Spring Security 系列的文章,但是整个 Spring Security 体系强大却又繁杂。陆陆续续从最开始的 guides 接触它,到项目中看了一些源码,到最近这个月为了写一写这个系列的文章,阅读了好几遍文档,最终打算尝试一下,写一个较为完整的系列文章。

较为简单或者体量较小的技术,完全可以参考着 demo 直接上手,但系统的学习一门技术则不然。以我的认知,一般的文档大致有两种风格:Architecture First 和 Code First。前者致力于让读者先了解整体的架构,方便我们对自己的认知有一个宏观的把控,而后者以特定的 demo 配合讲解,可以让读者在解决问题的过程中顺便掌握一门技术。关注过我博客或者公众号的朋友会发现,我之前介绍技术的文章,大多数是 Code First,提出一个需求,介绍一个思路,解决一个问题,分析一下源码,大多如此。而学习一个体系的技术,我推荐 Architecture First,正如本文标题所言,这篇文章是我 Spring Security 系列的第一篇,主要是根据 Spring Security 文档选择性 ~~ 翻译 ~~ 整理而成的一个架构概览,配合自己的一些注释方便大家理解。写作本系列文章时,参考版本为 Spring Security 4.2.3.RELEASE。

[TOC]

1 核心组件

这一节主要介绍一些在 Spring Security 中常见且核心的 Java 类,它们之间的依赖,构建起了整个框架。想要理解整个架构,最起码得对这些类眼熟。

1.1 SecurityContextHolder

SecurityContextHolder 用于存储安全上下文(security context)的信息。当前操作的用户是谁,该用户是否已经被认证,他拥有哪些角色权限… 这些都被保存在 SecurityContextHolder 中。SecurityContextHolder 默认使用 ThreadLocal 策略来存储认证信息。看到 ThreadLocal 也就意味着,这是一种与线程绑定的策略。Spring Security 在用户登录时自动绑定认证信息到当前线程,在用户退出时,自动清除当前线程的认证信息。但这一切的前提,是你在 web 场景下使用 Spring Security,而如果是 Swing 界面,Spring 也提供了支持,SecurityContextHolder 的策略则需要被替换,鉴于我的初衷是基于 web 来介绍 Spring Security,所以这里以及后续,非 web 的相关的内容都一笔带过。

获取当前用户的信息

因为身份信息是与线程绑定的,所以可以在程序的任何地方使用静态方法获取用户信息。一个典型的获取当前登录用户的姓名的例子如下所示:

1
2
3
4
5
6
7
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();

if (principal instanceof UserDetails) {
String username = ((UserDetails)principal).getUsername();
} else {
String username = principal.toString();
}

getAuthentication()返回了认证信息,再次 getPrincipal() 返回了身份信息,UserDetails 便是 Spring 对身份信息封装的一个接口。Authentication 和 UserDetails 的介绍在下面的小节具体讲解,本节重要的内容是介绍 SecurityContextHolder 这个容器。

查看更多

分享到

浅析分布式下的事件驱动机制(PubSub 模式)

上一篇文章《浅析 Spring 中的事件驱动机制》简单介绍了 Spring 对事件的支持。Event 的整个生命周期,从 publisher 发出,经过 applicationContext 容器通知到 EventListener,都是发生在单个 Spring 容器中,而在分布式场景下,有些时候一个事件的产生,可能需要被多个实例响应,本文主要介绍分布式场景下的事件驱动机制,由于使用了 Redis,ActiveMQ,也可以换一个名词来理解:分布式下的发布订阅模式。

JMS 规范

在日常项目开发中,我们或多或少的发现一些包一些类位于 java 或 javax 中,他们主要提供抽象类,接口,提供了一种规范,如 JPA,JSR,JNDI,JTA,JMS,他们是由 java 指定的标准规范,一流企业做标准、二流企业做品牌、三流企业做产品,虽然有点调侃的意味,但也可以见得它的重要意义。而 JMS 就是 java 在消息服务上指定的标准

The Java Message Service (JMS) API is a messaging standard that allows application components based on the Java Platform Enterprise Edition (Java EE) to create, send, receive, and read messages. It enables distributed communication that is loosely coupled, reliable, and asynchronous.

JMS(JAVA Message Service,java 消息服务)API 是一个消息服务的标准或者说是规范,允许应用程序组件基于 JavaEE 平台创建、发送、接收和读取消息。它使分布式通信耦合度更低,消息服务更加可靠以及异步性。

消息中间件有非常多的实现,如 ActiveMQ,RabbitMQ,RocketMQ,而他们同一遵循的接口规范,便是 JMS。在下文中即将出现的 ConnectionFactory,Destination,Connection,Session,MessageListener,Topic,Queue 等等名词,都是 JMS 核心的接口,由于本文的初衷并不是讲解 MQ&JMS,所以这些机制暂且跳过。

定义分布式事件需求

在上一个项目中,我们对接了外网的 http 接口,而安全性的保障则是交给 OAuth2 来完成,作为 OAuth2 的客户端,我们需要获取服务端返回的 token,而 token 接口的获取次数每个月是有限制的,于是我们选择使用 Redis 来保存,定时刷新。由于每次发起请求时都要携带 token,为了更高的性能减少一次 redis io,我们在 TokenService 中使用了本地变量缓存 token。于是形成如下的 token 获取机制:

token 获取流程

这个图并不复杂,只是为了方便描述需求:首先去本地变量中加载 token,若 token==null,则去 Redis 加载,若 Redis 未命中(token 过期了),则最终调用外部的 http 接口获取实时的 token,同时存入 redis 中和本地变量中。

这个需求设计到这样一个问题:大多数情况下是单个实例中发现 redis 中的 token 为空,而它需要同时获取最新 token,并通知其他的实例也去加载最新的 token,这个时候事件广播就可以派上用场了。

由于 token 缓存在了 Redis 中,我们首先介绍 Redis 的发布订阅机制。

查看更多

分享到

上一个电商项目的反思

加入中科软已经有了一个年头,从去年实习到今年转正,陆陆续续接触了大概四个项目。有电商类,互联网保险类,也经历过管理系统。幸运的是,这些项目都是从零开始,避免了让我去维护不堪入目的老旧系统。而这么多项目中令我印象最深刻的,就要属上一个电商项目了。这也是我接触到的真正意义的第一个微服务项目,到今天回首去看曾经的这个项目,有很多突破性地尝试,同时不可避免地也踩入了一些坑点,毕竟摸着石头过河。今天想聊聊我对上一个电商项目的反思。

项目简介

准确的说是一个第三方的电商项目,商品来源是由主流电商的 http 接口提供(目前接入了京东,苏宁),打造我们自己的商城体系。使用的技术包括 springboot,jpa,rpc 框架使用的是 motan,数据库使用的是 oracle,基本都还算是主流的技术。

盲目地拆分微服务

使用了 springboot 就是微服务了吗?使用 rpc 通信就是微服务了吗?刚接触到所谓的微服务架构时,无疑是让人兴奋的,但也没有太多的经验,以至于每提出一个新的需求,几乎就会新建一个服务。没有从宏观去思考如何拆分服务,那时还没有项目组成员尝试去使用领域驱动设计的思想去划分服务的边界,会议室中讨论最多的话题也是:我们的数据库该如何设计,而不是我们的领域该如何划分。项目初期,使用单体式的思想开发着分布式项目,新技术的引入还只是使人有点稍微的不顺手,但是项目越做越大后,越来越大的不适感逐渐侵蚀着我们的开发速度。

说道微服务的拆分,有很多个维度,这里主要谈两个维度:

  • 系统维度:业务功能不同的需求,交给不同的系统完成,如订单,商品,地址,用户等系统需要拆分。
  • 模块维度:基础架构层(如公用 util),领域层,接口层,服务层,表现层的拆分。

在项目的初期,我们错误地认为微服务的拆分仅仅是系统维度的拆分,如商品系统和订单系统,而在模块维度上,缺少拆分的意识,如订单模块的表现层和服务层,我们虽然做了隔离(两个独立的 tomcat)。但在后来,业务添加了一个新的需求:商城增加积分支持,让用户可以使用积分购买商品。我们突然发现,所谓的服务层和表现层严重的耦合,仅仅是在物理上进行了隔离,逻辑层面并没有拆分,这导致新的积分服务模块从原先的订单服务层拷贝了大量的代码。吸取了这个教训后,我们新的项目中采取了如下的分层方式:

新的架构分层

其中比较关键的一点便是表现层与应用层的完全分离,交互完全使用 DTO 对象。不少同事产生了困惑,抱怨在表现层不能访问数据库,这让他们获取数据变得十分“麻烦”,应用层和表现层还多了一次数据拷贝的工作,用于将 DO 持久化对象转换成 DTO 对象。但这样的好处从长远来看,是不言而喻的。总结为以下几点:

1 应用层高度重用,没有表现形式的阻碍,PC 端,移动端,外部服务都可以同时接入,需要组装什么样的数据,请自行组装。

2 应用层和领域层可以交由经验较为丰富的程序员负责,避免了一些低性能的数据操作,错误的并发控制等等。

3 解决远程调用数据懒加载的问题。从前的设计中,表现层拿到了领域层的对象,而领域层会使用懒加载技术,当表现层想要获取懒加载属性时,或得到一个 no session 的异常。在没有这个分层之前,如何方便地解决这个问题一度困扰了我们很长的一段时间。

数据库的滥用

项目使用了 oracle,我们所有的数据都存在于同一个 oracle 实例中,各个系统模块并没有做到物理层面的数据库隔离。这并不符合设计,一方面这给那些想要跨模块执行 join 操作的人留了后门,如执行订单模块和用户模块的级联查询;另一方面,还困扰了一部分对微服务架构不甚了解的程序员,在他们的想法中,同一个数据库实例反而方便了他们的数据操作。

严格意义上,不仅仅是不同系统之间的数据库不能互相访问。同一个系统维度的不同模块也应当限制,正如前面一节的分层架构中,表现层(web 层)是不应该出现 DAO 的,pom 文件中也不应该出现任何 JPA,Hibernate,Mybatis 一类的依赖,它所有的数据来源,必须是应用层。

另外一方面,由于历史遗留问题,需要对接一个老系统,他们的表和这个电商的 oracle 实例是同一个,而我竟然在他们的表上发现了触发器这种操作… 在新的项目中,我们已经禁止使用数据库层面的触发器和物理约束。

在新的项目中,我们采用了阿里云的 RDS(mysql) 作为 oracle 的替代品,核心业务数据则放到了分布式数据库 DRDS 中,严格做到了数据库层面的拆分。

并发的控制

电商系统不同于 OA 系统,CMS 系统,余额,订单等等操作都是敏感操作,实实在在跟钱打交道的东西容不得半点马虎,然而即使是一些有经验的程序员,也写出了这样的扣减余额操作:

1
2
3
4
5
6
7
8
public void reduce(String accountId,BigDecimal cost){
Account account = accountService.findOne(accountId);
BigDecimal balance = account.getBalance();
if(balance > cost)
balance = balance - cost;// 用四则运算代替 BigDecimal 的 api,方便表达
account.setBalance(balance);
accountService.save(account);
}

很多人没有控制并发的意识,即使意识到了,也不知道如何根据业务场景采取合适的手段控制并发,是使用 JPA 中的乐观锁,还是使用数据库的行级自旋锁完成简单并发控制,还是 for update 悲观锁(这不建议被使用),还是基于 redis 或 zookeeper 一类的分布式锁?

这种错误甚至都不容许等到 code revivew 时才被发现,而应该是尽力地杜绝。

代码规范

小到 java 的变量的驼峰命名法,数据库中用‘_’分割单词,到业务代码该如何规范的书写,再到并发规范,性能调优。准确的说,没有人管理这些事,这样的工作落到了每个有悟性的开发者身上。模块公用的常量,系统公用的常量应当区分放置,禁止使用魔鬼数字,bool 变量名不能以 is 开头等等细小的但是重要的规范,大量的条件查询 findByxxx 污染了 DAO 层,完全可以被 predicates,criteria 替代,RESTFUL 规范指导设计 web 接口等等…

在新的项目中,一条条规范被逐渐添加到了项目单独的模块 READ.me 中。作为公司的一个 junior developer,在建议其他成员使用规范开发项目时,得到的回应通常是:我的功能都已经实现了,干嘛要改;不符合规范又怎么样,要改你改时。有时候也是挺无力的,算是个人的一点牢骚吧。

软件设计的一点不足

还是拿订单系统和商品系统来说事,虽然两个系统在物理上被拆分开了,但如果需要展示订单列表,订单详情,如今系统的设计会发起多次的远程调用,用于查询订单的归属商品,这是违背领域驱动设计的。订单中的商品就应当是归属于订单模块,正确的设计应该是使用冗余,代替频繁的跨网络节点远程调用。

另外一点便是高可用,由于机器内存的限制,所有的系统都只部署了单个实例,这其实并不是微服务的最佳实践。从系统应用,到 zookeeper,redis,mq 等中间件,都应当保证高可用,避免单点问题。没有真正实现做到横向扩展(知识理论上实现了),实在是有点遗憾。

系统没有熔断,降级处理,在新的项目中,由于我们引入了 Spring Cloud,很多地方都可以 out of box 式使用框架提供的 fallback 处理,而这上一个电商项目由于框架的限制以及接口设计之初就没有预想到要做这样的操作,使得可靠性再减了几分。

自动化运维的缺失

单体式应用的美好时代,只需要发布同一份 war 包。而微服务项目中,一切都变得不同,在我们这个不算特别庞大的电商系统中,需要被运行的服务模块也到达了 30-40 个。由于这个电商系统是部署在甲方自己的服务器中,一方面是业务部门的业务审批流程,一方面是如此众多的 jar 包运行,没有自动发布,没有持续集成。令我比较难忘的是初期发布版本,始终有一两个服务莫名奇妙的挂掉,对着终端中的服务列表,一个个排查,这种痛苦的经历。至今,这个系统仍然依靠运维人员,手动管理版本。

上一个项目有一些不可控的项目因素,而新的项目中,系统服务全部在阿里云上部署,也引入了 Jenkins,一切都在逐渐变好,其他的 devops 工具仍然需要完善,以及 docker 一类的容器技术还未在计划日程之内,这些都是我们今年努力的目标。

总结

原本积累了很多自己的想法,可惜落笔之后能够捕捉到一些点,便只汇聚成了上述这些,而这上一个电商项目在逐渐的迭代开发之后也变得越来越好了(我去了新的项目组后,其他同事负责了后续的开发)。这个经历,于我是非常珍贵的,它比那些大牛直接告诉我微服务设计的要素要更加有意义。知道了不足之处,经历了自己解决问题的过程,才会了解到好的方案的优势,了解到开源方案到底是为了解决什么样的问题而设计的。

分享到

浅析 Spring 中的事件驱动机制

今天来简单地聊聊事件驱动,其实写这篇文章挺令我挺苦恼的,因为事件驱动这个名词,我没有找到很好的定性解释,担心自己的表述有误,而说到事件驱动可能立刻联想到如此众多的概念:观察者模式,发布订阅模式,消息队列 MQ,消息驱动,事件,EventSourcing… 为了不产生歧义,笔者把自己所了解的这些模棱两可的概念都列了出来,再开始今天的分享。

  • 在设计模式中,观察者模式可以算得上是一个非常经典的行为型设计模式,猫叫了,主人醒了,老鼠跑了,这一经典的例子,是事件驱动模型在设计层面的体现。
  • 另一模式,发布订阅模式往往被人们等同于观察者模式,但我的理解是两者唯一区别,是发布订阅模式需要有一个调度中心,而观察者模式不需要,例如观察者的列表可以直接由被观察者维护。不过两者即使被混用,互相替代,通常不影响表达。
  • MQ,中间件级别的消息队列(e.g. ActiveMQ,RabbitMQ),可以认为是发布订阅模式的一个具体体现。事件驱动 -> 发布订阅 ->MQ,从抽象到具体。
  • java 和 spring 中都拥有 Event 的抽象,分别代表了语言级别和三方框架级别对事件的支持。
  • EventSourcing 这个概念就要关联到领域驱动设计,DDD 对事件驱动也是非常地青睐,领域对象的状态完全是由事件驱动来控制,由其衍生出了 CQRS 架构,具体实现框架有 AxonFramework。
  • Nginx 可以作为高性能的应用服务器(e.g. openResty),以及 Nodejs 事件驱动的特性,这些也是都是事件驱动的体现。

本文涵盖的内容主要是前面 4 点。

Spring 对 Event 的支持

Spring 的文档对 Event 的支持翻译之后描述如下:

ApplicationContext 通过 ApplicationEvent 类和 ApplicationListener 接口进行事件处理。 如果将实现 ApplicationListener 接口的 bean 注入到上下文中,则每次使用 ApplicationContext 发布 ApplicationEvent 时,都会通知该 bean。 本质上,这是标准的观察者设计模式。

而在 spring4.2 之后,提供了注解式的支持,我们可以使用任意的 java 对象配合注解达到同样的效果,首先来看看不适用注解如何在 Spring 中使用事件驱动机制。

定义业务需求:用户注册后,系统需要给用户发送邮件告知用户注册成功,需要给用户初始化积分;隐含的设计需求,用户注册后,后续需求可能会添加其他操作,如再发送一条短信等等,希望程序具有扩展性,以及符合开闭原则。

如果不使用事件驱动,代码可能会像这样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class UserService {

@Autowired
EmailService emailService;
@Autowired
ScoreService scoreService;
@Autowired
OtherService otherService;

public void register(String name) {
System.out.println("用户:" + name + "已注册!");
emailService.sendEmail(name);
scoreService.initScore(name);
otherService.execute(name);
}

}

要说有什么毛病,其实也不算有,因为可能大多数人在开发中都会这么写,喜欢写同步代码。但这么写,实际上并不是特别的符合隐含的设计需求,假设增加更多的注册项 service,我们需要修改 register 的方法,并且让 UserService 注入对应的 Service。而实际上,register 并不关心这些“额外”的操作,如何将这些多余的代码抽取出去呢?便可以使用 Spring 提供的 Event 机制。

定义用户注册事件

1
2
3
4
5
6
7
public class UserRegisterEvent extends ApplicationEvent{

public UserRegisterEvent(String name) { //name 即 source
super(name);
}

}

ApplicationEvent 是由 Spring 提供的所有 Event 类的基类,为了简单起见,注册事件只传递了 name(可以复杂的对象,但注意要了解清楚序列化机制)。

定义用户注册服务 (事件发布者)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Service // <1>
public class UserService implements ApplicationEventPublisherAware { // <2>

public void register(String name) {
System.out.println("用户:" + name + "已注册!");
applicationEventPublisher.publishEvent(new UserRegisterEvent(name));// <3>
}

private ApplicationEventPublisher applicationEventPublisher; // <2>

@Override
public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) { // <2>
this.applicationEventPublisher = applicationEventPublisher;
}
}

<1> 服务必须交给 Spring 容器托管

<2> ApplicationEventPublisherAware 是由 Spring 提供的用于为 Service 注入 ApplicationEventPublisher 事件发布器的接口,使用这个接口,我们自己的 Service 就拥有了发布事件的能力。

<3> 用户注册后,不再是显示调用其他的业务 Service,而是发布一个用户注册事件。

定义邮件服务,积分服务,其他服务 (事件订阅者)

1
2
3
4
5
6
7
8
@Service // <1>
public class EmailService implements ApplicationListener<UserRegisterEvent> { // <2>

@Override
public void onApplicationEvent(UserRegisterEvent userRegisterEvent) {
System.out.println("邮件服务接到通知,给" + userRegisterEvent.getSource() + "发送邮件...");// <3>
}
}

<1> 事件订阅者的服务同样需要托管于 Spring 容器

<2> ApplicationListener<E extends ApplicationEvent> 接口是由 Spring 提供的事件订阅者必须实现的接口,我们一般把该 Service 关心的事件类型作为泛型传入。

<3> 处理事件,通过 event.getSource() 即可拿到事件的具体内容,在本例中便是用户的姓名。

其他两个 Service,也同样编写,实际的业务操作仅仅是打印一句内容即可,篇幅限制,这里省略。

编写启动类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@SpringBootApplication
@RestController
public class EventDemoApp {

public static void main(String[] args) {
SpringApplication.run(EventDemoApp.class, args);
}

@Autowired
UserService userService;

@RequestMapping("/register")
public String register(){
userService.register("kirito");
return "success";
}
}

当我们调用 userService.register(“kirito”); 方法时,控制台打印信息如下:

用户注册

他们的顺序是无序的,如果需要控制顺序,需要重写 order 接口,这点不做介绍。其次,我们完成了用户注册和其他服务的解耦,这也是事件驱动的最大特性之一,如果需要在用户注册时完成其他操作,只需要再添加相应的事件订阅者即可。

Spring 对 Event 的注解支持

上述的几个接口已经非常清爽了,如果习惯使用注解,Spring 也提供了,不再需要显示实现

注解式的事件发布者

1
2
3
4
5
6
7
8
9
10
11
12
@Service
public class UserService {

public void register(String name) {
System.out.println("用户:" + name + "已注册!");
applicationEventPublisher.publishEvent(new UserRegisterEvent(name));
}

@Autowired
private ApplicationEventPublisher applicationEventPublisher;

}

Spring4.2 之后,ApplicationEventPublisher 自动被注入到容器中,采用 Autowired 即可获取。

注解式的事件订阅者

1
2
3
4
5
6
7
8
@Service
public class EmailService {

@EventListener
public void listenUserRegisterEvent(UserRegisterEvent userRegisterEvent) {
System.out.println("邮件服务接到通知,给" + userRegisterEvent.getSource() + "发送邮件...");
}
}

@EventListener 注解完成了 ApplicationListener<E extends ApplicationEvent> 接口的使命。

更多的特性可以参考 SpringFramework 的文档。

Spring 中事件的应用

在以往阅读 Spring 源码的经验中,接触了不少使用事件的地方,大概列了以下几个,加深以下印象:

  • Spring Security 中使用 AuthenticationEventPublisher 处理用户认证成功,认证失败的消息处理。

    1
    2
    3
    4
    5
    6
    7
    public interface AuthenticationEventPublisher {

    void publishAuthenticationSuccess(Authentication authentication);

    void publishAuthenticationFailure(AuthenticationException exception,
    Authentication authentication);
    }
  • Hibernate 中持久化对象属性的修改是如何被框架得知的?正是采用了一系列持久化相关的事件,如 DefaultSaveEventListenerDefaultUpdateEventListener, 事件非常多,有兴趣可以去 org.hibernate.event 包下查看。

  • Spring Cloud Zuul 中刷新路由信息使用到的 ZuulRefreshListener

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    private static class ZuulRefreshListener implements ApplicationListener<ApplicationEvent> {
    ...

    public void onApplicationEvent(ApplicationEvent event) {
    if(!(event instanceof ContextRefreshedEvent) && !(event instanceof RefreshScopeRefreshedEvent) && !(event instanceof RoutesRefreshedEvent)) {
    if(event instanceof HeartbeatEvent && this.heartbeatMonitor.update(((HeartbeatEvent)event).getValue())) {
    this.zuulHandlerMapping.setDirty(true);
    }
    } else {
    this.zuulHandlerMapping.setDirty(true);
    }

    }
    }
  • Spring 容器生命周期相关的一些默认 Event

    1
    ContextRefreshedEvent,ContextStartedEvent,ContextStoppedEvent,ContextClosedEvent,RequestHandledEvent

    。。。其实吧,非常多。。。

总结

本文暂时只介绍了 Spring 中的一些简单的事件驱动机制,相信如果之后再看到 Event,Publisher,EventListener 一类的单词后缀时,也能立刻和事件机制联系上了。再阅读 Spring 源码时,如果发现出现了某个 Event,但由于不是同步调用,所以很容易被忽视,我一般习惯下意识的去寻找有没有提供默认的 Listener,这样不至于漏掉一些“隐藏”的特性。下一篇文章打算聊一聊分布式场景下,事件驱动使用的注意点。

公众号刚刚创立,如果觉得文章不错,希望能分享到您的朋友圈,如果对文章有什么想法和建议,可以与我沟通。

分享到

从 Feign 使用注意点到 RESTFUL 接口设计规范

最近项目中大量使用了 Spring Cloud Feign 来对接 http 接口,踩了不少坑,也产生了一些对 RESTFUL 接口设计的想法,特此一篇记录下。

[TOC]

SpringMVC 的请求参数绑定机制

了解 Feign 历史的朋友会知道,Feign 本身是 Netflix 的产品,Spring Cloud Feign 是在原生 Feign 的基础上进行了封装,引入了大量的 SpringMVC 注解支持,这一方面使得其更容易被广大的 Spring 使用者开箱即用,但也产生了不小的混淆作用。所以在使用 Spring Cloud Feign 之前,笔者先介绍一下 SpringMVC 的一个入参机制。预设一个 RestController,在本地的 8080 端口启动一个应用,用于接收 http 请求。

1
2
3
4
5
6
7
8
9
@RestController
public class BookController {

@RequestMapping(value = "/hello") // <1>
public String hello(String name) { // <2>
return "hello" + name;
}

}

这个接口写起来非常简单,但实际 springmvc 做了非常多的兼容,使得这个接口可以接受多种请求方式。

<1> RequestMapping 代表映射的路径,使用 GET,POST,PUT,DELETE 方式都可以映射到该端点。

<2> SpringMVC 中常用的请求参数注解有(@RequestParam,@RequestBody,@PathVariable)等。name 被默认当做 @RequestParam。形参 String name 由框架使用字节码技术获取 name 这个名称,自动检测请求参数中 key 值为 name 的参数,也可以使用 @RequestParam(“name”) 覆盖变量本身的名称。当我们在 url 中携带 name 参数或者 form 表单中携带 name 参数时,会被获取到。

1
2
3
4
5
POST /hello HTTP/1.1
Host: localhost:8080
Content-Type: application/x-www-form-urlencoded

name=formParam

1
2
GET /hello?name=queryString HTTP/1.1
Host: localhost:8080

Feign 的请求参数绑定机制

上述的 SpringMVC 参数绑定机制,大家应该都是非常熟悉的,但这一切在 Feign 中有些许的不同。

我们来看一个非常简单的,但是实际上错误的接口写法:

1
2
3
4
5
6
7
8
// 注意:错误的接口写法
@FeignClient("book")
public interface BookApi {

@RequestMapping(value = "/hello",method = RequestMethod.GET)
String hello(String name);

}

配置请求地址:

1
2
3
4
5
6
7
ribbon:
eureka:
enabled: false

book:
ribbon:
listOfServers: http://localhost:8080

我们按照写 SpringMVC 的 RestController 的习惯写了一个 FeignClient,按照我们的一开始的想法,由于指定了请求方式是 GET,那么 name 应该会作为 QueryString 拼接到 Url 中吧?发出一个这样的 GET 请求:

1
2
GET /hello?name=xxx HTTP/1.1
Host: localhost:8080

而实际上,RestController 并没有接收到,我们在 RestController 一侧的应用中获得了一些提示:

服务端 DEBUG 信息

  • 并没有按照期望使用 GET 方式发送请求,而是 POST 方式
  • name 参数没有被封装,获得了一个 null 值

查看文档发现,如果不加默认的注解,Feign 则会对参数默认加上 @RequestBody 注解,而 RequestBody 一定是包含在请求体中的,GET 方式无法包含。所以上述两个现象得到了解释。Feign 在 GET 请求包含 RequestBody 时强制转成了 POST 请求,而不是报错。

理解清楚了这个机制我们就可以在开发 Feign 接口避免很多坑。而解决上述这个问题也很简单

  • 在 Feign 接口中为 name 添加 @RequestParam(“name”) 注解,name 必须指定,Feign 的请求参数不会利用 SpringMVC 字节码的机制自动给定一个默认的名称。
  • 由于 Feign 默认使用 @RequestBody,也可以改造 RestController,使用 @RequestBody 接收。但是,请求参数通常是多个,推荐使用上述的 @RequestParam,而 @RequestBody 一般只用于传递对象。

Feign 绑定复合参数

指定请求参数的类型与请求方式,上述问题的出现实际上是由于在没有理清楚 Feign 内部机制的前提下想当然的和 SpringMVC 进行了类比。同样,在使用对象作为参数时,也需要注意这样的问题。

对于这样的接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@FeignClient("book")
public interface BookApi {

@RequestMapping(value = "/book",method = RequestMethod.POST)
Book book(@RequestBody Book book); // <1>

@RequestMapping(value = "/book",method = RequestMethod.POST)
Book book(@RequestParam("id") String id,@RequestParam("name") String name); // <2>

@RequestMapping(value = "/book",method = RequestMethod.POST)
Book book(@RequestParam Map map); // <3>

// 错误的写法
@RequestMapping(value = "/book",method = RequestMethod.POST)
Book book(@RequestParam Book book); // <4>

}

<1> 使用 @RequestBody 传递对象是最常用的方式。

<2> 如果参数并不是很多,可以平铺开使用 @RequestParam

<3> 使用 Map,这也是完全可以的,但不太符合面向对象的思想,不能从代码立刻看出该接口需要什么样的参数。

<4> 错误的用法,Feign 没有提供这样的机制自动转换实体为 Map。

Feign 中使用 @PathVariable 与 RESTFUL 规范

这涉及到一个如何设计 RESTFUL 接口的话题,我们知道在自从 RESTFUL 在 2000 年初被提出来之后,就不乏文章提到资源,契约规范,CRUD 对应增删改查操作等等。下面笔者从两个实际的接口来聊聊自己的看法。

根据 id 查找用户接口:

1
2
3
4
5
6
7
@FeignClient("user")
public interface UserApi {

@RequestMapping(value = "/user/{userId}",method = RequestMethod.GET)
String findById(@PathVariable("id") String userId);

}

这应该是没有争议的,注意前面强调的,@PathVariable(“id”) 括号中的 id 不可以忘记。那如果是“根据邮箱查找用户呢”? 很有可能下意识的写出这样的接口:

1
2
3
4
5
6
7
@FeignClient("user")
public interface UserApi {

@RequestMapping(value = "/user/{email}",method = RequestMethod.GET)
String findByEmail(@PathVariable("email") String email);

}
  • 首先看看 Feign 的问题。email 中通常包含’.‘这个特殊字符,如果在路径中包含,会出现意想不到的结果。我不想探讨如何去解决它(实际上可以使用 {email:.+} 的方式), 因为我觉得这不符合设计。
  • 再谈谈规范的问题。这两个接口是否是相似的,email 是否应该被放到 path 中?这就要聊到 RESTFUL 的初衷,为什么 userId 这个属性被普遍认为适合出现在 RESTFUL 路径中,因为 id 本身起到了资源定位的作用,他是资源的标记。而 email 不同,它可能是唯一的,但更多的,它是资源的属性,所以,笔者认为不应该在路径中出现非定位性的动态参数。而是把 email 作为 @RequestParam 参数。

RESUFTL 结构化查询

笔者成功的从 Feign 的话题过度到了 RESTFUL 接口的设计问题,也导致了本文的篇幅变长了,不过也不打算再开一片文章谈了。

再考虑一个接口设计,查询某一个月某个用户的订单,可能还会携带分页参数,这时候参数变得很多,按照传统的设计,这应该是一个查询操作,也就是与 GET 请求对应,那是不是意味着应当将这些参数拼接到 url 后呢?再思考 Feign,正如本文的第二段所述,是不支持 GET 请求携带实体类的,这让我们设计陷入了两难的境地。而实际上参考一些 DSL 语言的设计如 elasticSearch,也是使用 POST JSON 的方式来进行查询的,所以在实际项目中,笔者并不是特别青睐 CRUD 与四种请求方式对应的这种所谓的 RESTFUL 规范,如果说设计 RESTFUL 应该遵循什么规范,那大概是另一些名词,如契约规范和领域驱动设计。

1
2
3
4
5
6
7
@FeignClient("order")
public interface BookApi {

@RequestMapping(value = "/order/history",method = RequestMethod.POST)
Page<List<Orders>> queryOrderHistory(@RequestBody QueryVO queryVO);

}

RESTFUL 行为限定

在实际接口设计中,我遇到了这样的需求,用户模块的接口需要支持修改用户密码,修改用户邮箱,修改用户姓名,而笔者之前阅读过一篇文章,也是讲舍弃 CRUD 而是用领域驱动设计来规范 RESTFUL 接口的定义,与项目中我的想法不谋而合。看似这三个属性是同一个实体类的三个属性,完全可以如下设计:

1
2
3
4
5
6
7
@FeignClient("user")
public interface UserApi {

@RequestMapping(value = "/user",method = RequestMethod.POST)
User update(@RequestBody User user);

}

但实际上,如果再考虑多一层,就应该产生这样的思考:这三个功能所需要的权限一致吗?真的应该将他们放到一个接口中吗?实际上,笔者并不希望接口调用方传递一个实体,因为这样的行为是不可控的,完全不知道它到底是修改了什么属性,如果真的要限制行为,还需要在 User 中添加一个操作类型的字段,然后在接口实现方加以校验,这太麻烦了。而实际上,笔者觉得规范的设计应当如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
@FeignClient("user")
public interface UserApi {

@RequestMapping(value = "/user/{userId}/password/update",method = RequestMethod.POST)
ResultBean<Boolean> updatePassword(@PathVariable("userId) String userId,@RequestParam("password") password);

@RequestMapping(value = "/user/{userId}/email/update",method = RequestMethod.POST)
ResultBean<Boolean> updateEmail(@PathVariable("userId) String userId,@RequestParam("email") String email);

@RequestMapping(value = "/user/{userId}/username/update",method = RequestMethod.POST)
ResultBean<Boolean> updateUsername(@PathVariable("userId) String userId,@RequestParam("username") String username);

}
  • 一般意义上 RESTFUL 接口不应该出现动词,这里的 update 并不是一个动作,而是标记着操作的类型,因为针对某个属性可能出现的操作类型可能会有很多,所以我习惯加上一个 update 后缀,明确表达想要进行的操作,而不是仅仅依赖于 GET,POST,PUT,DELETE。实际上,修改操作推荐使用的请求方式应当是 PUT,这点笔者的理解是,已经使用 update 标记了行为,实际开发中不习惯使用 PUT。
  • password,email,username 都是 user 的属性,而 userId 是 user 的识别符号,所以 userId 以 PathVariable 的形式出现在 url 中,而三个属性出现在 ReqeustParam 中。

顺带谈谈逻辑删除,如果一个需求是删除用户的常用地址,这个 api 的操作类型,我通常也不会设计为 DELETE 请求,而是同样使用 delete 来标记操作行为

1
2
@RequestMapping(value = "/user/{userId}/address/{addressId}/delete",method = RequestMethod.POST)
ResultBean<Boolean> updateEmail(@PathVariable("userId") String userId,@PathVariable("userId") String email);

总结

本文从 Feign 的使用注意点,聊到了 RESTFUL 接口的设计问题,其实是一个互相补充的行为。接口设计需要载体,所以我以 Feign 的接口风格谈了谈自己对 RESTFUL 设计的理解,而 Feign 中一些坑点,也正是我想要规范 RESTFUL 设计的出发点。如有对 RESTFUL 设计不同的理解,欢迎与我沟通。

分享到