文章目录
本篇将介绍 SpringSecurity 结合 JWT (
J
son
W
eb
T
oken) 实现身份认证和权限验证.
代码仓库
概念
什么是 JWT
JWT 是一种 access-token. 内容是用英文点号连接起来的分成三个部分 (Header, Payload, Signature) 的字符串.
相较于 Session 技术, 通过 JWT 我们可以提供无状态的 Web 服务. 服务器不用再保存客户端的信息, 扩展性大大增强.
客户端把 JWT 保存到 localStorage 中, 每次对后端的请求都将 JWT 放到请求头中:
Authorization: Bearer <token>
, 服务端校验 JWT 中的信息完成身份验证.
出于安全考虑, JWT 不应存在敏感信息, 应保持时效性 (过期机制) 并始终配合 Https 使用.
参考链接
HTTP Authentication Bearer
参考链接
认证 (Authentication) 是构建 Web 应用的不可或缺的一环, 简单介绍一下常见的 HTTP Authentication 方式 (
Authentication Schemas
):
※ Basic (RFC 7617)
※ Bearer (
RFC 6750
)
※ Digest (
RFC 7616
)
※ HOBA (
RFC 7486
)
※
Mutual
※
AWS4-HMAC-SHA256
JWT 是一种 token 格式, 而 Bearer 是一种认证方案. HTTP 的请求头 Authorization 的值的格式就是
Authorization: <type> <credentials>
的形式, 支持多种鉴权方案, Bearer 只是其中一种 (通常搭配 JWT, 在 JWT 的前面加上
Bearer
, 这个值就是
Bearer Token
).
思路
我们设计三个端点:
-
/auth/login
: 登陆的端点. 前端携带有效用户信息换取 access-token (Json Web Token) 的端点; -
/auth/register
: 注册的端点. 该端点不会被身份认证和权限校验机制拦截, 是一个被放行的端点; -
/task/1
: 后续业务端点. 只有携带了有效 access-token 并有权限的请求才能访问此类端点;
由于还是采用了 SpringBoot Web + SpringSecurity 的架构, 仍然是基于过滤器的, 我们把身份认证的过滤器命名为
JWTAuthenticationFilter
, 权限校验的过滤器命名为
JWTAuthorizationFilter
. 且前者要先于后者执行.
来看一张描述这一过程的时序图:
首先, 请求通过 /auth/login 端点进行身份认证, 成功后会在响应头中置入 access-token;
随后, 获得 access-token 的请求同样在请求头中携带 access-token 访问后续服务. 这一过程会被权限校验过滤器拦截, 该过滤器会校验 access-token 是否有权限访问目标资源, 如果有就放行.
实现
为了实现这一思路, 我们首先需要有一张用户表, 表结构和数据如下
(在本文中, 我们只需要简单定义一张 User 表, 后续文章会有比较完备的实现)
:
模块结构
cn
└─caplike
└─demo
└─spring
└─security
└─jwt
│ JWTApplication.java
│
├─configuration
│ SecurityConfiguration.java
│ UsernamePasswordAuthenticationProvider.java
│
├─controller
│ AuthController.java
│ TaskController.java
│
├─domain
│ ├─dto
│ │ CustomUserDetails.java
│ │
│ └─entity
│ User.java
│
├─filter
│ JWTAuthenticationFilter.java
│ JWTAuthorizationFilter.java
│
├─mapper
│ UserMapper.java
│
├─service
│ UserDetailsServiceImpl.java
│
└─util
JWTUtils.java
和
上一篇
一致, 仍然从数据库中获取用户信息. 为此, 首先还是先来写配置
SecurityConfiguration
extends WebSecurityConfigurerAdapter
在
configure(AuthenticationManagerBuilder)
中配置身份管理器, 告诉 Security 用什么途径, 从什么地方读取用户信息. 本文采用实现
AuthenticationProvider
接口的方式. 比起简单地指定
UserDetailsService
(
auth.userDetailsService(T userDetailsService)
) 的方式, 自定义的
AuthenticationProvider
更加灵活.
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
private UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider;
@Override
protected void configure(AuthenticationManagerBuilder auth) {
// 指定自定义的 AuthenticationProvider
auth.authenticationProvider(usernamePasswordAuthenticationProvider);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// ref: demo-spring-security-csrf, 暂时禁用 csrf, 将在下一篇文章中细说
.csrf().disable()
.authorizeRequests()
.antMatchers("/auth/**").permitAll()
.anyRequest().hasAnyAuthority("ADMIN") // .authenticated()
.and()
.addFilter(new JWTAuthenticationFilter(authenticationManager()))
// 让校验 Token 的过滤器在身份认证过滤器之后
.addFilterAfter(new JWTAuthorizationFilter(), JWTAuthenticationFilter.class)
// 既然启用 JWT, 那就彻底点, 不需要 Session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
// ~ Autowired
// -----------------------------------------------------------------------------------------------------------------
@Autowired
public void setUsernamePasswordAuthenticationProvider(UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider) {
this.usernamePasswordAuthenticationProvider = usernamePasswordAuthenticationProvider;
}
}
AuthenticationProvider
SpringSecurity 提供了一系列选项完成认证, 归根结底, 这一过程的核心可以被描述为: 认证请求会被
org.springframework.security.authentication.AuthenticationProvider
处理, 最终会返回一个完整的, 认证过的认证对象:
org.springframework.security.core.Authentication
的实现 (这里我们将会采用
UsernamePasswordAuthenticationToken
):
我们继承
AuthenticationProvider
接口, 实现自己的处理
UsernamePasswordAuthenticationToken
的
UsernamePasswordAuthenticationProvider
类:
@Component
public class UsernamePasswordAuthenticationProvider implements AuthenticationProvider {
/**
* @see cn.caplike.demo.spring.security.jwt.service.UserDetailsServiceImpl
*/
private UserDetailsService userDetailsService;
/**
* 加密器
*/
private BCryptPasswordEncoder bCryptPasswordEncoder;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
// 获取用户输入的用户名和密码
String username = authentication.getName();
String password = authentication.getCredentials().toString();
// 获取封装用户信息的对象
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// 进行密码的比对
boolean flag = bCryptPasswordEncoder.matches(password, userDetails.getPassword());
// 校验通过
if (flag) {
// 将权限信息也封装进去
return new UsernamePasswordAuthenticationToken(userDetails, password, userDetails.getAuthorities());
}
throw new AuthenticationException("用户密码错误") {
};
}
@Override
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}
// ~ Autowired
// -----------------------------------------------------------------------------------------------------------------
@Autowired
public void setUserDetailsService(@Qualifier("userDetailsServiceImpl") UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
@Autowired
public void setBCryptPasswordEncoder(BCryptPasswordEncoder bCryptPasswordEncoder) {
this.bCryptPasswordEncoder = bCryptPasswordEncoder;
}
// ~ Bean
// -----------------------------------------------------------------------------------------------------------------
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
AuthenticationProvider
接口定义了两个方法:
Authentication authenticate(Authentication authentication)
用于执行认证. 接收一个认证请求对象, 返回一个完整的认证过的对象.
boolean suports(Class<?> authentication)
控制对于何种
Authentication
的实现, 启用当前
AuthenticationProvider
, 由于我们采用
UsernamePasswordAuthenticationToken
, 所以在这里也作出限制.
BCryptPasswordEncoder
是官方提供的其中一种密钥加密器, 用户的密码当然是加密存放了. (当然也可以实现其接口
PasswordEncoder
自行实现加密规则)
UserDetailsService
UserDetailsService
提供了获取用户信息的方法 (
UserDetails loadUserByUsername(String)
) 定义.
UserDetails
接口定义了用户核心信息, 它的实现类不会被 SpringSecurity 直接处理, 而会被封装到
Authentication
的实现类中, 后者的
Object getPrincipal();
返回值的真实类型就是
UserDetails
的实现. 在本文中就是
CustomUserDetails
.
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private UserMapper userMapper;
/**
* Description: 从数据库获取 UserDetails
*
* @param username 用户名
* @return org.springframework.security.core.userdetails.UserDetails
* @throws UsernameNotFoundException 当用户不存在
* @author LiKe
* @date 2020-04-26 08:59:01
* @see UserDetailsService#loadUserByUsername(String)
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return new CustomUserDetails(Optional.ofNullable(userMapper.findByUsername(username)).orElseThrow(() -> new UsernameNotFoundException("用户不存在!")));
}
@Autowired
public void setUserMapper(UserMapper userMapper) {
this.userMapper = userMapper;
}
}
JWTAuthenticationFilter & JWTAuthorizationFilter
从
SecurityConfiguration
的代码中可以看到, 我们配置了对
/auth/**
放行, 其中包含两个端点:
/auth/login
和
/auth/register
.
JWTAuthenticationFilter
extends UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter
. 是身份验证的过滤器. 主要职责是完成对请求中携带的用户信息的校验并颁发 access-token.
UsernamePasswordAuthenticationFilter
定义了过滤器的基本逻辑, 在构造方法中可以看到该过滤器默认对 POST 方式, 且 URI 是
/login
的请求才会拦截:
public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
public UsernamePasswordAuthenticationFilter() {
super(new AntPathRequestMatcher("/login", "POST"));
}
我们期望的请求是携带形如
{"name": "", "password": ""}
的用户身份数据到后端的, 所以需要把默认的 usernameParameter 从 username 改成 name, 并把这个 URI 改成我们期望的
/auth/login
, 认证成功后会将 access-token 放在响应头中回执给前端. 完整的
JWTAuthenticationFilter
如下:
public class JWTAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
public JWTAuthenticationFilter(AuthenticationManager authenticationManager) {
this.authenticationManager = authenticationManager;
// 浏览器访问 http://localhost:18902/auth/login 会通过 JWTAuthenticationFilter
super.setFilterProcessesUrl("/auth/login");
super.setUsernameParameter("name");
}
@SneakyThrows
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
// 数据是通过 requestBody 传输
User user = JSON.parseObject(request.getInputStream(), StandardCharsets.UTF_8, User.class);
return authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(user.getName(), user.getPassword())
);
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,
FilterChain chain,
Authentication authResult) {
log.debug("authentication filter successful authentication: {}", authResult);
// 如果验证成功, 就生成 Token 并返回
CustomUserDetails customUserDetails = (CustomUserDetails) authResult.getPrincipal();
response.setHeader("access-token",
JWTUtils.TOKEN_PREFIX + JWTUtils.create(customUserDetails.getName(), false, customUserDetails));
}
/**
* 如果 attemptAuthentication 抛出 AuthenticationException 则会调用这个方法
*
* @see UsernamePasswordAuthenticationFilter#unsuccessfulAuthentication(HttpServletRequest, HttpServletResponse, AuthenticationException)
*/
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
AuthenticationException failed) throws IOException {
log.debug("authentication filter unsuccessful authentication: {}", failed.getMessage());
response.getWriter().write("authentication failed, reason: " + failed.getMessage());
}
}
authenticationManager.authenticate
方法最终会调用在
SecurityConfiguration#configure(AuthenticationManagerBuilder)
中指定的
AuthenticationProvider
的
authenticate
从而执行
UserDetailsService
的
loadUserByUsername
获取用户信息.
super.setFilterProcessesUrl("/auth/login");
表明这个过滤器会且仅会监听
/auth/login
端点.
JWTAuthenticationFilter
覆盖了
UsernamePasswordAuthenticationFilter
的
-
Authentication attemptAuthentication
-
void successfulAuthentication
-
void unsuccessfulAuthentication
.
介绍一下三个方法的调用逻辑:
-
一个请求到来, 首先会验证这个请求是否应当被这个过滤器拦截. 不满足条件则会直接被放行. 依据就是
UsernamePasswordAuthenticationFilter
的构造方法中传入的
AntPathRequestMatcher
, 在我们自己的
JWTAuthenticationFilter
中通过
setFilterProcessesUrl
指定了
/auth/login
, 这个方法会通过超类的
setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher(filterProcessesUrl));
设置
AntPathRequestMatcher
; -
紧接着会调用
attemptAuthentication
执行验证, 这个方法期望返回一个
Authentication
的实现类, 返回 null 表示认证没有完成. -
如果
attemptAuthentication
抛出
AuthenticationException
异常则会触发
unsuccessfulAuthentication
方法, 并且异常本身会作为目标方法的第三个参数传入; -
最后, 在以上几步都通过的情况下会调用
successfulAuthentication
方法, 并将
attemptAuthentication
认证结果对象作为参数传入.
剩下的部分, 有前面的说明, 相信
JWTAuthenticationFilter
看起来就很简单了.
JWTAuthorizationFilter
这个过滤器用于对携带 access-token 的请求执行权限检查. 所以, 除了注册端点之外的所有请求都应该被它过滤.
理论上在认证成功后我们应该把 access-token 缓存起来, 并设置合适的过期时间. 当携带有效 access-token 的请求到来的时候, 也应该适时地延长该令牌的过期时间. 关于这部分逻辑, 后续文章会有更为详细的实现.
完整代码如下:
public class JWTAuthorizationFilter extends OncePerRequestFilter {
private static final Set<String> WHITE_LIST = Stream.of("/auth/register").collect(Collectors.toSet());
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
log.debug("authorization filter doFilterInternal");
final String authorization = request.getHeader(JWTUtils.TOKEN_HEADER);
log.debug("raw-access-token: {}", authorization);
// Branch A: 如果请求头中没有 Authorization
if (StringUtils.isBlank(authorization)) {
// 白名单放行
if (WHITE_LIST.contains(request.getRequestURI())) {
chain.doFilter(request, response);
} else {
response.getWriter().write("未经授权的访问!");
}
return;
}
// Branch B: 如果请求头中有 Bear xxx, 设置认证信息
final String jsonWebToken = authorization.replace(JWTUtils.TOKEN_PREFIX, StringUtils.EMPTY);
// TODO 用 Redis 的过期控制 token, 而不用 jwt 的 Expiration
// if (JWTUtils.hasExpired(jsonWebToken)) {
// response.getWriter().write("access-token 已过期, 请重新登陆!");
// }
// TODO 每一次携带正确 token 的访问, 都刷新 Redis 的过期时间
CustomUserDetails customUserDetails = JWTUtils.userDetails(jsonWebToken);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(
customUserDetails.getName(),
// TODO Json Web Token 中不能携带用户密码
customUserDetails.getPassword(),
customUserDetails.getAuthorities()
)
);
chain.doFilter(request, response);
}
}
在
doFilterInterneal
方法最后, 我们把存在 JWT 中的用户信息取出, 构造了一个完整的
Authentication
对象. 其中,
getAuthorities
方法体现用户权限信息. Security 会根据
SecurityContext
中的
Authentication
的
authorities
判断当前用户是否有权限访问目标资源.
其他说明
UserMapper
ORM 框架本例采用了 MyBatis. 主要是用于获取用户相关信息.
@Repository
@Mapper
public interface UserMapper {
/**
* Description: 查找 User
*
* @param username 用户名
* @return cn.caplike.demo.spring.security.jwt.domain.entity.User
* @author LiKe
* @date 2020-04-22 09:04:06
*/
@Select("SELECT * FROM USER WHERE name = #{username}")
User findByUsername(String username);
/**
* Description: 新建 User
*
* @param user {@link User}
* @return cn.caplike.demo.spring.security.jwt.domain.entity.User
* @author LiKe
* @date 2020-04-22 11:18:21
*/
@Insert("INSERT INTO USER(name, password, role) VALUES (#{name}, #{password}, #{role})")
int save(User user);
}
JWTUtils
是实现生成和解析 Json Web Token 的工具类. 需要引入相关依赖:
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.1</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.1</version>
<scope>runtime</scope>
</dependency>
代码如下:
public final class JWTUtils {
public static final String TOKEN_HEADER = "Authorization";
public static final String TOKEN_PREFIX = "Bearer ";
private static final String SECRET = "5ae95dd3c5f811b9b819434910c52820ae7cfb3d9f7961e7117b24a7012873767d79f61f81fc2e06ebb6fd4f09ab47764d6e20607f843c22a0a2a6a6ed829680";
/**
* 签发人
*/
private static final String ISSUER = "caplike";
private JWTUtils() {
}
/**
* Description: 创建 Json Web Token
*
* @param username {String} 用户名
* @param rememberMe {boolean} 是否记住我
* @param userDetails {@link CustomUserDetails} 的实现类
* @return java.lang.String Json Web Token
* @author LiKe
* @date 2020-04-21 16:18:10
*/
public static String create(String username, boolean rememberMe, CustomUserDetails userDetails) {
return Jwts.builder()
// [Attention] 要先 setClaims(初始化底层 map) 再设置 subject, 如果 subject 先设置, 会被覆盖.
.setClaims(JSON.parseObject(JSON.toJSONString(userDetails)))
// 主题
.setSubject(username)
// 颁发时间
.setIssuedAt(Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant()))
// 颁发人
.setIssuer(ISSUER)
.signWith(Keys.hmacShaKeyFor(SECRET.getBytes()), SignatureAlgorithm.HS512)
.serializeToJsonWith(map -> JSON.toJSONBytes(map))
.compact();
}
/**
* Description: 获得 subject
*
* @param jwt {String} Json Web Token
* @return java.lang.String subject
* @author LiKe
* @date 2020-04-21 18:09:26
*/
public static String subject(String jwt) {
return claims(jwt).getSubject();
}
public static CustomUserDetails userDetails(String jwt) {
return JSON.parseObject(JSON.toJSONString(claims(jwt)), CustomUserDetails.class);
}
private static Claims claims(String jwt) {
return Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(SECRET.getBytes()))
.deserializeJsonWith(bytes -> JSONObject.parseObject(new String(bytes), new TypeReference<Map<String, Object>>() {
}))
.build()
.parseClaimsJws(jwt)
.getBody();
}
}
测试
最后, 来测试一波. 如之前所述, 本例提供两个
Controller
:
AuthController
@RestController
@RequestMapping("/auth")
public class AuthController {
private UserMapper userMapper;
private BCryptPasswordEncoder bCryptPasswordEncoder;
/**
* Description: 注册
*
* @param registerUser 用户信息
* @return java.lang.String
* @author LiKe
* @date 2020-04-24 09:22:54
* @see cn.caplike.demo.spring.security.jwt.filter.JWTAuthenticationFilter
* @see cn.caplike.demo.spring.security.jwt.filter.JWTAuthorizationFilter
*/
@PostMapping("/register")
public User registerUser(@RequestBody Map<String, String> registerUser) {
User user = new User();
user.setName(registerUser.get("name"));
// 记得注册的时候把密码加密一下
user.setPassword(bCryptPasswordEncoder.encode(registerUser.get("password")));
user.setRole("USER");
log.debug("AuthController: {}", userMapper.save(user));
return user;
}
@Autowired
public void setBCryptPasswordEncoder(BCryptPasswordEncoder bCryptPasswordEncoder) {
this.bCryptPasswordEncoder = bCryptPasswordEncoder;
}
@Autowired
public void setUserMapper(UserMapper userMapper) {
this.userMapper = userMapper;
}
}
TaskController
@RestController
@RequestMapping("/task")
public class TaskController {
@GetMapping
public String listTasks() {
return "任务列表";
}
@PostMapping
public String newTasks() {
return "创建了一个新的任务";
}
@PutMapping("/{taskId}")
public String updateTasks(@PathVariable("taskId") Integer id) {
return "更新了一下 id: " + id + " 的任务";
}
@DeleteMapping("/{taskId}")
public String deleteTasks(@PathVariable("taskId") Integer id) {
return "删除了 id: " + id + " 的任务";
}
}
启动项目用 Postman 测试:
登陆请求
控制台输出:
... .f.JWTAuthenticationFilter : Request is to process authentication
... .CustomAuthenticationProvider : CustomAuthenticationProvider: supports: class org.springframework.security.authentication.UsernamePasswordAuthenticationToken
... .m.UserMapper.findByUsername : ==> Preparing: SELECT * FROM USER WHERE name = ?
... .m.UserMapper.findByUsername : ==> Parameters: root(String)
... .m.UserMapper.findByUsername : <== Total: 1
... .f.JWTAuthenticationFilter : authentication filter successful authentication: org.springframework.security.authentication.UsernamePasswordAuthenticationToken@d0448fa4: Principal: CustomUserDetails(name=root, password=$2a$10$yjx08FV8gARdM7YinjFp7u.aD3dyDYgBwzWl84qYFLFLNhn3R1Vs2, authorities=[ADMIN]); Credentials: [PROTECTED]; Authenticated: true; Details: null; Granted Authorities: ADMIN
访问资源
用合法的 access-token 访问后端资源
控制台输出:
... .JWTAuthorizationFilter : authorization filter doFilterInternal
... .JWTAuthorizationFilter : raw-access-token: Bearer eyJhbGciOiJIUzUxMiJ9.eyJwYXNzd29yZCI6IiQyYSQxMCR5angwOEZWOGdBUmRNN1lpbmpGcDd1LmFEM2R5RFlnQnd6V2w4NHFZRkxGTE5objNSMVZzMiIsImNyZWRlbnRpYWxzTm9uRXhwaXJlZCI6dHJ1ZSwibmFtZSI6InJvb3QiLCJhY2NvdW50Tm9uRXhwaXJlZCI6dHJ1ZSwiYXV0aG9yaXRpZXMiOlt7ImF1dGhvcml0eSI6IkFETUlOIn1dLCJlbmFibGVkIjp0cnVlLCJhY2NvdW50Tm9uTG9ja2VkIjp0cnVlLCJ1c2VybmFtZSI6InJvb3QiLCJzdWIiOiJyb290IiwiaWF0IjoxNTg5NTI2MDI1LCJpc3MiOiJjYXBsaWtlIn0.bui-_EX5S_tkoT94dMQoavkRVJZV0Yq9_-JMlS30sRRP4-F0DLB3TLxU4w2MU2pVm4vrFfk8JCyrtCJRYE9B0Q
把数据库中 root 用户的权限改成 USER, 重启服务, 重新登陆 (获取新权限下的 access-token) 再次尝试访问
/task/1
端点:
返回 403:
{
"timestamp": "2020-05-15T08:10:17.767+0000",
"status": 403,
"error": "Forbidden",
"message": "Forbidden",
"path": "/jwt/task/1"
}
总结
本文详细介绍了如何用 JWT 做 access-token, 依托 SpringSecurity 完成用户身份认证和权限校验.
但是仍有几处不足:
- csrf 在本例中未作处理, 而是直接禁用的, 显然不够安全;
- access-token 没有缓存未作过期;
- 权限是写死的, 每次更改权限需要重启服务;
- 整个模块返回信息结构仍然是框架提供的默认结构, 很多场合下我们更希望用自己统一的结构返回给前端, 无论是异常还是正常;
这些问题都会在接下来的文章中探讨.
– END –
Reference