如果您想了解SpringBoot如何统一后端返回格式?老鸟们都是这样玩的!的相关知识,那么本文是一篇不可错过的文章,我们将对springboot返回页面和数据进行全面详尽的解释,并且为您提供关于Spr
如果您想了解SpringBoot 如何统一后端返回格式?老鸟们都是这样玩的!的相关知识,那么本文是一篇不可错过的文章,我们将对springboot返回页面和数据进行全面详尽的解释,并且为您提供关于Spring Boot 中如何统一 API 接口响应格式?、Spring Boot 接口封装统一返回格式、SpringBoot + Vue 搭建前后端分离的博客项目系统(SpringBoot 部分)、springboot 中后端返回数据给前端的设计模式的有价值的信息。
本文目录一览:- SpringBoot 如何统一后端返回格式?老鸟们都是这样玩的!(springboot返回页面和数据)
- Spring Boot 中如何统一 API 接口响应格式?
- Spring Boot 接口封装统一返回格式
- SpringBoot + Vue 搭建前后端分离的博客项目系统(SpringBoot 部分)
- springboot 中后端返回数据给前端的设计模式
SpringBoot 如何统一后端返回格式?老鸟们都是这样玩的!(springboot返回页面和数据)
大家好,我是磊哥。
今天我们来聊一聊在基于 SpringBoot 前后端分离开发模式下,如何友好的返回统一的标准格式以及如何优雅的处理全局异常。
首先我们来看看为什么要返回统一的标准格式?
为什么要对 SpringBoot 返回统一的标准格式
在默认情况下,SpringBoot 的返回格式常见的有三种:
第一种:返回 String
@GetMapping("/hello")
public String getStr(){
return "hello,javadaily";
}
此时调用接口获取到的返回值是这样:
hello,javadaily
第二种:返回自定义对象
@GetMapping("/aniaml")
public Aniaml getAniaml(){
Aniaml aniaml = new Aniaml(1,"pig");
return aniaml;
}
此时调用接口获取到的返回值是这样:
{
"id": 1,
"name": "pig"
}
第三种:接口异常
@GetMapping("/error")
public int error(){
int i = 9/0;
return i;
}
此时调用接口获取到的返回值是这样:
{
"timestamp": "2021-07-08T08:05:15.423+00:00",
"status": 500,
"error": "Internal Server Error",
"path": "/wrong"
}
基于以上种种情况,如果你和前端开发人员联调接口她们就会很懵逼,由于我们没有给他一个统一的格式,前端人员不知道如何处理返回值。
还有甚者,有的同学比如小张喜欢对结果进行封装,他使用了 Result 对象,小王也喜欢对结果进行包装,但是他却使用的是 Response 对象,当出现这种情况时我相信前端人员一定会抓狂的。
所以我们项目中是需要定义一个统一的标准返回格式的。
定义返回标准格式
一个标准的返回格式至少包含 3 部分:
-
status 状态值:由后端统一定义各种返回结果的状态码 -
message 描述:本次接口调用的结果描述 -
data 数据:本次返回的数据。
{
"status":"100",
"message":"操作成功",
"data":"hello,javadaily"
}
当然也可以按需加入其他扩展值,比如我们就在返回对象中添加了接口调用时间
-
timestamp: 接口调用时间
定义返回对象
@Data
public class ResultData<T> {
/** 结果状态 ,具体状态码参见ResultData.java*/
private int status;
private String message;
private T data;
private long timestamp ;
public ResultData (){
this.timestamp = System.currentTimeMillis();
}
public static <T> ResultData<T> success(T data) {
ResultData<T> resultData = new ResultData<>();
resultData.setStatus(ReturnCode.RC100.getCode());
resultData.setMessage(ReturnCode.RC100.getMessage());
resultData.setData(data);
return resultData;
}
public static <T> ResultData<T> fail(int code, String message) {
ResultData<T> resultData = new ResultData<>();
resultData.setStatus(code);
resultData.setMessage(message);
return resultData;
}
}
定义状态码
public enum ReturnCode {
/**操作成功**/
RC100(100,"操作成功"),
/**操作失败**/
RC999(999,"操作失败"),
/**服务限流**/
RC200(200,"服务开启限流保护,请稍后再试!"),
/**服务降级**/
RC201(201,"服务开启降级保护,请稍后再试!"),
/**热点参数限流**/
RC202(202,"热点参数限流,请稍后再试!"),
/**系统规则不满足**/
RC203(203,"系统规则不满足要求,请稍后再试!"),
/**授权规则不通过**/
RC204(204,"授权规则不通过,请稍后再试!"),
/**access_denied**/
RC403(403,"无访问权限,请联系管理员授予权限"),
/**access_denied**/
RC401(401,"匿名用户访问无权限资源时的异常"),
/**服务异常**/
RC500(500,"系统异常,请稍后重试"),
INVALID_TOKEN(2001,"访问令牌不合法"),
ACCESS_DENIED(2003,"没有权限访问该资源"),
CLIENT_AUTHENTICATION_FAILED(1001,"客户端认证失败"),
USERNAME_OR_PASSWORD_ERROR(1002,"用户名或密码错误"),
UNSUPPORTED_GRANT_TYPE(1003, "不支持的认证模式");
/**自定义状态码**/
private final int code;
/**自定义描述**/
private final String message;
ReturnCode(int code, String message){
this.code = code;
this.message = message;
}
public int getCode() {
return code;
}
public String getMessage() {
return message;
}
}
统一返回格式
@GetMapping("/hello")
public ResultData<String> getStr(){
return ResultData.success("hello,javadaily");
}
此时调用接口获取到的返回值是这样:
{
"status": 100,
"message": "hello,javadaily",
"data": null,
"timestamp": 1625736481648,
"httpStatus": 0
}
这样确实已经实现了我们想要的结果,我在很多项目中看到的都是这种写法,在 Controller 层通过 ResultData.success()
对返回结果进行包装后返回给前端。
看到这里我们不妨停下来想想,这样做有什么弊端呢?
最大的弊端就是我们后面每写一个接口都需要调用 ResultData.success()
这行代码对结果进行包装,重复劳动,浪费体力;
而且还很容易被其他老鸟给嘲笑。

所以呢我们需要对代码进行优化,目标就是不要每个接口都手工制定 ResultData
返回值。
高级实现方式
要优化这段代码很简单,我们只需要借助 SpringBoot 提供的 ResponseBodyAdvice
即可。
“ResponseBodyAdvice 的作用:拦截 Controller 方法的返回值,统一处理返回值 / 响应体,一般用来统一返回格式,加解密,签名等等。
”
先来看下 ResponseBodyAdvice
的源码:
public interface ResponseBodyAdvice<T> {
/**
* 是否支持advice功能
* true 支持,false 不支持
*/
boolean supports(MethodParameter var1, Class<? extends HttpMessageConverter<?>> var2);
/**
* 对返回的数据进行处理
*/
@Nullable
T beforeBodyWrite(@Nullable T var1, MethodParameter var2, MediaType var3, Class<? extends HttpMessageConverter<?>> var4, ServerHttpRequest var5, ServerHttpResponse var6);
}
我们只需要编写一个具体实现类即可
/**
* @author jam
* @date 2021/7/8 10:10 上午
*/
@RestControllerAdvice
public class ResponseAdvice implements ResponseBodyAdvice<Object> {
@Autowired
private ObjectMapper objectMapper;
@Override
public boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> aClass) {
return true;
}
@SneakyThrows
@Override
public Object beforeBodyWrite(Object o, MethodParameter methodParameter, MediaType mediaType, Class<? extends HttpMessageConverter<?>> aClass, ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {
if(o instanceof String){
return objectMapper.writeValueAsString(ResultData.success(o));
}
return ResultData.success(o);
}
}
需要注意两个地方:
-
@RestControllerAdvice
注解@RestControllerAdvice
是@RestController
注解的增强,可以实现三个方面的功能: -
全局异常处理 -
全局数据绑定 -
全局数据预处理 -
String 类型判断
if(o instanceof String){
return objectMapper.writeValueAsString(ResultData.success(o));
}
这段代码一定要加,如果 Controller 直接返回 String 的话,SpringBoot 是直接返回,故我们需要手动转换成 json。
经过上面的处理我们就再也不需要通过 ResultData.success()
来进行转换了,直接返回原始数据格式,SpringBoot 自动帮我们实现包装类的封装。
@GetMapping("/hello")
public String getStr(){
return "hello,javadaily";
}
此时我们调用接口返回的数据结果为:
@GetMapping("/hello")
public String getStr(){
return "hello,javadaily";
}
是不是感觉很完美,别急,还有个问题在等着你呢。

接口异常问题
此时有个问题,由于我们没对 Controller 的异常进行处理,当我们调用的方法一旦出现异常,就会出现问题,比如下面这个接口
@GetMapping("/wrong")
public int error(){
int i = 9/0;
return i;
}
返回的结果为:

这显然不是我们想要的结果,接口都报错了还返回操作成功的响应码,前端看了会打人的。
别急,接下来我们进入第二个议题,如何优雅的处理全局异常。
SpringBoot 为什么需要全局异常处理器
-
不用手写 try...catch,由全局异常处理器统一捕获
使用全局异常处理器最大的便利就是程序员在写代码时不再需要手写
try...catch
了,前面我们讲过,默认情况下 SpringBoot 出现异常时返回的结果是这样:
{
"timestamp": "2021-07-08T08:05:15.423+00:00",
"status": 500,
"error": "Internal Server Error",
"path": "/wrong"
}
这种数据格式返回给前端,前端是看不懂的,所以这时候我们一般通过 try...catch
来处理异常
@GetMapping("/wrong")
public int error(){
int i;
try{
i = 9/0;
}catch (Exception e){
log.error("error:{}",e);
i = 0;
}
return i;
}
我们追求的目标肯定是不需要再手动写 try...catch
了,而是希望由全局异常处理器处理。
-
对于自定义异常,只能通过全局异常处理器来处理
@GetMapping("error1")
public void empty(){
throw new RuntimeException("自定义异常");
}
-
当我们引入 Validator 参数校验器的时候,参数校验不通过会抛出异常,此时是无法用
try...catch
捕获的,只能使用全局异常处理器。“
SpringBoot 集成参数校验请参考这篇文章 SpringBoot 开发秘籍 - 集成参数校验及高阶技巧
”
如何实现全局异常处理器
@Slf4j
@RestControllerAdvice
public class RestExceptionHandler {
/**
* 默认全局异常处理。
* @param e the e
* @return ResultData
*/
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ResultData<String> exception(Exception e) {
log.error("全局异常信息 ex={}", e.getMessage(), e);
return ResultData.fail(ReturnCode.RC500.getCode(),e.getMessage());
}
}
有三个细节需要说明一下:
-
@RestControllerAdvice
,RestController 的增强类,可用于实现全局异常处理器 -
@ExceptionHandler
, 统一处理某一类异常,从而减少代码重复率和复杂度,比如要获取自定义异常可以@ExceptionHandler(BusinessException.class)
-
@ResponseStatus
指定客户端收到的 http 状态码
体验效果
这时候我们调用如下接口:
@GetMapping("error1")
public void empty(){
throw new RuntimeException("自定义异常");
}
返回的结果如下:
{
"status": 500,
"message": "自定义异常",
"data": null,
"timestamp": 1625795902556
}
基本满足我们的需求了。
但是当我们同时启用统一标准格式封装功能 ResponseAdvice
和 RestExceptionHandler
全局异常处理器时又出现了新的问题:
{
"status": 100,
"message": "操作成功",
"data": {
"status": 500,
"message": "自定义异常",
"data": null,
"timestamp": 1625796167986
},
"timestamp": 1625796168008
}
此时返回的结果是这样,统一格式增强功能会给返回的异常结果再次封装,所以接下来我们需要解决这个问题。
全局异常接入返回的标准格式
要让全局异常接入标准格式很简单,因为全局异常处理器已经帮我们封装好了标准格式,我们只需要直接返回给客户端即可。
@SneakyThrows
@Override
public Object beforeBodyWrite(Object o, MethodParameter methodParameter, MediaType mediaType, Class<? extends HttpMessageConverter<?>> aClass, ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {
if(o instanceof String){
return objectMapper.writeValueAsString(ResultData.success(o));
}
if(o instanceof ResultData){
return o;
}
return ResultData.success(o);
}
关键代码:
if(o instanceof ResultData){
return o;
}
如果返回的结果是 ResultData 对象,直接返回即可。
这时候我们再调用上面的错误方法,返回的结果就符合我们的要求了。
{
"status": 500,
"message": "自定义异常",
"data": null,
"timestamp": 1625796580778
}
好了,今天的文章就到这里了,希望通过这篇文章你能掌握如何在你项目中友好实现统一标准格式到返回并且可以优雅的处理全局异常。
github 地址:https://github.com/jianzh5/cloud-blog/
文末福利
最后磊哥为了回馈广大的读者朋友,决定送 3 本疯狂 Java 李刚新作《疯狂 Spring Boot 终极讲义》横跨 WebFlux、Redis、ElasticSearch、Kafka、K8s 等 7 大整合方向,并有高并发秒杀系统案例,赠视频及代码,免费包邮到家。(日常在看转发较多的小伙伴获奖概率更高哦,本周五公布中奖名单~)
当然,不差钱的朋友也可以直接下单购买,此书正在京东、当当限时 5 折促销,下单地址:
本文分享自微信公众号 - Java 中文社群(javacn666)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与 “OSC 源创计划”,欢迎正在阅读的你也加入,一起分享。
Spring Boot 中如何统一 API 接口响应格式?
今天又要给大家介绍一个 Spring Boot 中的组件 --HandlerMethodReturnValueHandler。
在前面的文章中(如何优雅的实现 Spring Boot 接口参数加密解密?),松哥已经和大家介绍过如何对请求 / 响应数据进行预处理 / 二次处理,当时我们使用了 ResponseBodyAdvice 和 RequestBodyAdvice。其中 ResponseBodyAdvice 可以实现对响应数据的二次处理,可以在这里对响应数据进行加密 / 包装等等操作。不过这不是唯一的方案,今天松哥要和大家介绍一种更加灵活的方案 --HandlerMethodReturnValueHandler,我们一起来看看下。
1.HandlerMethodReturnValueHandler
HandlerMethodReturnValueHandler 的作用是对处理器的处理结果再进行一次二次加工,这个接口里边有两个方法:
public interface HandlerMethodReturnValueHandler {
boolean supportsReturnType(MethodParameter returnType);
void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType,
ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception;
}
- supportsReturnType:这个处理器是否支持相应的返回值类型。
- handleReturnValue:对方法返回值进行处理。
HandlerMethodReturnValueHandler 有很多默认的实现类,我们来看下:
接下来我们来把这些实现类的作用捋一捋:
ViewNameMethodReturnValueHandler
这个处理器用来处理返回值为 void 和 String 的情况。如果返回值为 void,则不做任何处理。如果返回值为 String,则将 String 设置给 mavContainer 的 viewName 属性,同时判断这个 String 是不是重定向的 String,如果是,则设置 mavContainer 的 redirectModelScenario 属性为 true,这是处理器返回重定向视图的标志。
ViewMethodReturnValueHandler
这个处理器用来处理返回值为 View 的情况。如果返回值为 View,则将 View 设置给 mavContainer 的 view 属性,同时判断这个 View 是不是重定向的 View,如果是,则设置 mavContainer 的 redirectModelScenario 属性为 true,这是处理器返回重定向视图的标志。
MapMethodProcessor
这个处理器用来处理返回值类型为 Map 的情况,具体的处理方案就是将 map 添加到 mavContainer 的 model 属性中。
StreamingResponseBodyReturnValueHandler
这个用来处理 StreamingResponseBody 或者 ResponseEntity<streamingresponsebody>
类型的返回值。
DeferredResultMethodReturnValueHandler
这个用来处理 DeferredResult、ListenableFuture 以及 CompletionStage 类型的返回值,用于异步请求。
CallableMethodReturnValueHandler
处理 Callable 类型的返回值,也是用于异步请求。
HttpHeadersReturnValueHandler
这个用来处理 HttpHeaders 类型的返回值,具体处理方式就是将 mavContainer 中的 requestHandled 属性设置为 true,该属性是请求是否已经处理完成的标志(如果处理完了,就到此为止,后面不会再去找视图了),然后将 HttpHeaders 添加到响应头中。
ModelMethodProcessor
这个用来处理返回值类型为 Model 的情况,具体的处理方式就是将 Model 添加到 mavContainer 的 model 上。
ModelAttributeMethodProcessor
这个用来处理添加了 @ModelAttribute
注解的返回值类型,如果 annotaionNotRequired 属性为 true,也可以用来处理其他非通用类型的返回值。
ServletModelAttributeMethodProcessor
同上,该类只是修改了参数解析方式。
ResponseBodyEmitterReturnValueHandler
这个用来处理返回值类型为 ResponseBodyEmitter
的情况。
ModelAndViewMethodReturnValueHandler
这个用来处理返回值类型为 ModelAndView
的情况,将返回值中的 Model 和 View 分别设置到 mavContainer 的相应属性上去。
ModelAndViewResolverMethodReturnValueHandler
这个的 supportsReturnType 方法返回 true,即可以处理所有类型的返回值,这个一般放在最后兜底。
AbstractMessageConverterMethodProcessor
这是一个抽象类,当返回值需要通过 HttpMessageConverter 进行转化的时候会用到它的子类。这个抽象类主要是定义了一些工具方法。
RequestResponseBodyMethodProcessor
这个用来处理添加了 @ResponseBody
注解的返回值类型。
HttpEntityMethodProcessor
这个用来处理返回值类型是 HttpEntity 并且不是 RequestEntity 的情况。
AsyncHandlerMethodReturnValueHandler
这是一个空接口,暂未发现典型使用场景。
AsyncTaskMethodReturnValueHandler
这个用来处理返回值类型为 WebAsyncTask 的情况。
HandlerMethodReturnValueHandlerComposite
看 Composite 就知道,这是一个组合处理器,没啥好说的。
这个就是系统默认定义的 HandlerMethodReturnValueHandler。
那么在上面的介绍中,大家看到反复涉及到一个组件 mavContainer,这个我也要和大家介绍一下。
2.ModelAndViewContainer
ModelAndViewContainer 就是一个数据穿梭巴士,在整个请求的过程中承担着数据传送的工作,从它的名字上我们可以看出来它里边保存着 Model 和 View 两种类型的数据,但是实际上可不止两种,我们来看下 ModelAndViewContainer 的定义:
public class ModelAndViewContainer {
private boolean ignoreDefaultModelOnRedirect = false;
@Nullable
private Object view;
private final ModelMap defaultModel = new BindingAwareModelMap();
@Nullable
private ModelMap redirectModel;
private boolean redirectModelScenario = false;
@Nullable
private HttpStatus status;
private final Set<string> noBinding = new HashSet<>(4);
private final Set<string> bindingDisabled = new HashSet<>(4);
private final SessionStatus sessionStatus = new SimpleSessionStatus();
private boolean requestHandled = false;
}
把这几个属性理解了,基本上也就整明白 ModelAndViewContainer 的作用了:
- defaultModel:默认使用的 Model。当我们在接口参数重使用 Model、ModelMap 或者 Map 时,最终使用的实现类都是 BindingAwareModelMap,对应的也都是 defaultModel。
- redirectModel:重定向时候的 Model,如果我们在接口参数中使用了 RedirectAttributes 类型的参数,那么最终会传入 redirectModel。
可以看到,一共有两个 Model,两个 Model 到底用哪个呢?这个在 getModel 方法中根据条件返回合适的 Model:
public ModelMap getModel() {
if (useDefaultModel()) {
return this.defaultModel;
}
else {
if (this.redirectModel == null) {
this.redirectModel = new ModelMap();
}
return this.redirectModel;
}
}
private boolean useDefaultModel() {
return (!this.redirectModelScenario || (this.redirectModel == null && !this.ignoreDefaultModelOnRedirect));
}
这里 redirectModelScenario 表示处理器是否返回 redirect 视图;ignoreDefaultModelOnRedirect 表示是否在重定向时忽略 defaultModel,所以这块的逻辑是这样:
- 如果 redirectModelScenario 为 true,即处理器返回的是一个重定向视图,那么使用 redirectModel。如果 redirectModelScenario 为 false,即处理器返回的不是一个重定向视图,那么使用 defaultModel。
- 如果 redirectModel 为 null,并且 ignoreDefaultModelOnRedirect 为 false,则使用 redirectModel,否则使用 defaultModel。
接下来还剩下如下一些参数:
- view:返回的视图。
- status:HTTP 状态码。
- noBinding:是否对 @ModelAttribute (binding=true/false) 声明的数据模型的相应属性进行绑定。
- bindingDisabled:不需要进行数据绑定的属性。
- sessionStatus:SessionAttribute 使用完成的标识。
- requestHandled:请求处理完成的标识(例如添加了
@ResponseBody
注解的接口,这个属性为 true,请求就不会再去找视图了)。
> 这个 ModelAndViewContainer 小伙伴们权且做一个了解,松哥在后面的源码分析中,还会和大家再次聊到这个组件。
接下来我们也来自定义一个 HandlerMethodReturnValueHandler,来感受一下 HandlerMethodReturnValueHandler 的基本用法。
3.API 接口数据包装
假设我有这样一个需求:我想在原始的返回数据外面再包裹一层,举个简单例子,本来接口是下面这样:
@RestController
public class UserController {
@GetMapping("/user")
public User getUserByUsername(String username) {
User user = new User();
user.setUsername(username);
user.setAddress("www.javaboy.org");
return user;
}
}
返回的数据格式是下面这样:
{"username":"javaboy","address":"www.javaboy.org"}
现在我希望返回的数据格式变成下面这样:
{"status":"ok","data":{"username":"javaboy","address":"www.javaboy.org"}}
就这样一个简单需求,我们一起来看下怎么实现。
3.1 RequestResponseBodyMethodProcessor
在开始定义之前,先给大家介绍一下 RequestResponseBodyMethodProcessor,这是 HandlerMethodReturnValueHandler 的实现类之一,这个主要用来处理返回 JSON 的情况。
我们来稍微看下:
@Override
public boolean supportsReturnType(MethodParameter returnType) {
return (AnnotatedElementUtils.hasAnnotation(returnType.getContainingClass(), ResponseBody.class) ||
returnType.hasMethodAnnotation(ResponseBody.class));
}
@Override
public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType,
ModelAndViewContainer mavContainer, NativeWebRequest webRequest)
throws IOException, HttpMediaTypeNotAcceptableException, HttpMessageNotWritableException {
mavContainer.setRequestHandled(true);
ServletServerHttpRequest inputMessage = createInputMessage(webRequest);
ServletServerHttpResponse outputMessage = createOutputMessage(webRequest);
writeWithMessageConverters(returnValue, returnType, inputMessage, outputMessage);
}
- supportsReturnType:从这个方法中可以看到,这里支持有
@ResponseBody
注解的接口。 - handleReturnValue:这是具体的处理逻辑,首先 mavContainer 中设置 requestHandled 属性为 true,表示这里处理完成后就完了,以后不用再去找视图了,然后分别获取 inputMessage 和 outputMessage,调用 writeWithMessageConverters 方法进行输出,writeWithMessageConverters 方法是在父类中定义的方法,这个方法比较长,核心逻辑就是调用确定输出数据、确定 MediaType,然后通过 HttpMessageConverter 将 JSON 数据写出去即可。
有了上面的知识储备之后,接下来我们就可以自己实现了。
3.2 具体实现
首先自定义一个 HandlerMethodReturnValueHandler:
public class MyHandlerMethodReturnValueHandler implements HandlerMethodReturnValueHandler {
private HandlerMethodReturnValueHandler handler;
public MyHandlerMethodReturnValueHandler(HandlerMethodReturnValueHandler handler) {
this.handler = handler;
}
@Override
public boolean supportsReturnType(MethodParameter returnType) {
return handler.supportsReturnType(returnType);
}
@Override
public void handleReturnValue(Object returnValue, MethodParameter returnType, ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception {
Map<string, object> map = new HashMap<>();
map.put("status", "ok");
map.put("data", returnValue);
handler.handleReturnValue(map, returnType, mavContainer, webRequest);
}
}
由于我们要做的功能其实是在 RequestResponseBodyMethodProcessor 基础之上实现的,因为支持 @ResponseBody
,输出 JSON 那些东西都不变,我们只是在输出之前修改一下数据而已。所以我这里直接定义了一个属性 HandlerMethodReturnValueHandler,这个属性的实例就是 RequestResponseBodyMethodProcessor,supportsReturnType 方法就按照 RequestResponseBodyMethodProcessor 的要求来,在 handleReturnValue 方法中,我们先对返回值进行一个预处理,然后调用 RequestResponseBodyMethodProcessor#handleReturnValue 方法继续输出 JSON 即可。
接下来就是配置 MyHandlerMethodReturnValueHandler 使之生效了。由于 SpringMVC 中 HandlerAdapter 在加载的时候已经配置了 HandlerMethodReturnValueHandler(这块松哥以后会和大家分析相关源码),所以我们可以通过如下方式对已经配置好的 RequestMappingHandlerAdapter 进行修改,如下:
@Configuration
public class ReturnValueConfig implements InitializingBean {
@Autowired
RequestMappingHandlerAdapter requestMappingHandlerAdapter;
@Override
public void afterPropertiesSet() throws Exception {
List<handlermethodreturnvaluehandler> originHandlers = requestMappingHandlerAdapter.getReturnValueHandlers();
List<handlermethodreturnvaluehandler> newHandlers = new ArrayList<>(originHandlers.size());
for (HandlerMethodReturnValueHandler originHandler : originHandlers) {
if (originHandler instanceof RequestResponseBodyMethodProcessor) {
newHandlers.add(new MyHandlerMethodReturnValueHandler(originHandler));
}else{
newHandlers.add(originHandler);
}
}
requestMappingHandlerAdapter.setReturnValueHandlers(newHandlers);
}
}
自定义 ReturnValueConfig 实现 InitializingBean 接口,afterPropertiesSet 方法会被自动调用,在该方法中,我们将 RequestMappingHandlerAdapter 中已经配置好的 HandlerMethodReturnValueHandler 拎出来挨个检查,如果类型是 RequestResponseBodyMethodProcessor,则重新构建,用我们自定义的 MyHandlerMethodReturnValueHandler 代替它,最后给 requestMappingHandlerAdapter 重新设置 HandlerMethodReturnValueHandler 即可。
最后再提供一个测试接口:
@RestController
public class UserController {
@GetMapping("/user")
public User getUserByUsername(String username) {
User user = new User();
user.setUsername(username);
user.setAddress("www.javaboy.org");
return user;
}
}
public class User {
private String username;
private String address;
//省略其他
}
配置完成后,就可以启动项目啦。
项目启动成功后,访问 /user
接口,如下:
完美。
4. 小结
其实统一 API 接口响应格式办法很多,可以参考松哥之前分享的 如何优雅的实现 Spring Boot 接口参数加密解密?,也可以使用本文中的方案,甚至也可以自定义过滤器实现。
本文的内容稍微有点多,不知道大家有没有发现松哥最近发了很多 SpringMVC 源码相关的东西,没错,本文其实是松哥 SpringMVC 源码解析的一部分,为了源码解析不那么枯燥,所以强行加了一个案例进来,祝小伙伴们学习愉快~</handlermethodreturnvaluehandler></handlermethodreturnvaluehandler></string,></string></string></streamingresponsebody>
Spring Boot 接口封装统一返回格式
前几天搬砖的时候,发现所有接口方法都定义了一样的返回值,不能真正地将业务逻辑表达出来,没有达到“望文生意”的效果。着手改造一下。
虽然标题是Spring Boot,但是这个接口在包spring-webmvc.jar
下(请原谅我这个标题党)。ResponseBodyAdvice
接口类路径:
org.springframework.web.servlet.mvc.method.annotation
首先,我们需要定义一个统一的状态码ResultCode
,来统一工程中异常情况的描述:
/**
* 统一状态返回码
* 三级状态码不满足时,可返回二级宏观码,依次类推
* 状态码参考 alibaba 《JAVA开发手册-泰山版》
*/
public enum ResultCode {
OK("00000", "成功"),
/** 一级宏观错误码 */
CLIENT_ERROR("A0001", "用户端错误 "),
/** 二级宏观错误码 */
USER_REGISTER_ERROR("A0100", "用户注册错误"),
USER_DISAGREE_PRIVACY_PROTOCOL("A0101", "用户未同意隐私协议"),
REGION_REGISTER_LIMITED("A0102","注册国家或地区受限"),
VALIDATE_USERNAME_FAILED("A0110","用户名校验失败"),
USERNAME_EXISTED("A0111","用户名已存在"),
/* 中间还有好多,鉴于篇幅,就不贴出来了 */
MAIL_NOTICE_FAILED("C0503", "邮件提醒服务失败");
private String status;
private String message;
ResultCode(String status, String message) {
this.status = status;
this.message = message;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
@Override
public String toString() {
return "ResultCode{" +
"status=''" + status + ''\'''' +
", message=''" + message + ''\'''' +
''}'';
}
}
枚举的好处我就不多说了,想必大家也经常使用HttpStatus
这个枚举。
其次,我们封装一个数据传输对象Result
,这个类就用来封装我们统一的返回格式:
import com.jason.enums.ResultCode;
import com.fasterxml.jackson.annotation.JsonView;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import org.springframework.data.domain.Page;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
/**
* 统一返回DTO
* 通过 JsonView 尽可能控制返回格式的精简,酌情使用
*/
@ApiModel
public class Result {
public interface commonResult{};
public interface standardResult extends commonResult{};
@ApiModelProperty(value = "status", name = "响应状态码")
private String status;
@ApiModelProperty(value = "message", name = "响应信息")
private String message;
@ApiModelProperty(value = "body", name = "响应内容")
private Object body;
public Result() {
this.status = ResultCode.OK.getStatus();
this.message = ResultCode.OK.getMessage();
}
public Result(ResultCode resultCode) {
this.status = resultCode.getStatus();
this.message = resultCode.getMessage();
}
public Result(ResultCode resultCode, String message) {
this(resultCode);
this.message = message;
}
public Result(Object body) {
this.status = ResultCode.OK.getStatus();
this.message = ResultCode.OK.getMessage();
this.body = body;
}
public Result(ResultCode resultCode, Object body) {
this(body);
this.status = resultCode.getStatus();
this.message = resultCode.getMessage();
}
public Result(Collection collection) {
this.status = ResultCode.OK.getStatus();
this.message = ResultCode.OK.getMessage();
this.body = collection;
}
public Result(ResultCode resultCode, Collection collection) {
this(collection);
this.status = resultCode.getStatus();
this.message = resultCode.getMessage();
}
public Result(Page page) {
this.status = ResultCode.OK.getStatus();
this.message = ResultCode.OK.getMessage();
Map<String, Object> info = new HashMap<>(8);
info.put("totalItem", page.getTotalElements());
info.put("pageSize", page.getNumber());
info.put("pageNum", page.getSize());
info.put("totalPage", page.getTotalPages());
info.put("item", page.getContent());
this.body = info;
}
public Result(ResultCode resultCode, Page page) {
this(page);
this.status = resultCode.getStatus();
this.message = resultCode.getMessage();
}
@JsonView(commonResult.class)
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
@JsonView(commonResult.class)
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
@JsonView(standardResult.class)
public Object getBody() {
return body;
}
public void setBody(Object body) {
this.body = body;
}
@Override
public String toString() {
return "Result{" +
"status=''" + status + ''\'''' +
", message=''" + message + ''\'''' +
", body=" + body +
''}'';
}
}
这个类每个人定义的方式不一样,包括构造方法或者是否用static
修饰,大家都可以根据自身情况实现。
最后,我们再实现接口ResponseBody
,可以根据一些条件来控制:
import com.jason.dto.Result;
import com.jason.enums.ResultCode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
/**
* 统一返回对象配置类
* create by Jason
*/
@ControllerAdvice
public class ResultBodyConfig implements ResponseBodyAdvice<Object> {
private Logger logger = LoggerFactory.getLogger(this.getClass());
private final static String PACKAGE_PATH = "com.jason.component";
/**
* 针对以下情况 不做 统一包装处理
* 1.返回值为 void 的方法
* 2.返回值为 String 类型的方法
* 3.返回值为 Result 类型的方法
* 4.在包路径 PACKAGE_PATH 以外的方法
* @param methodParameter
* @param aClass
* @return
*/
@Override
public boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> aClass) {
return !methodParameter.getMethod().getReturnType().isAssignableFrom(Void.TYPE)
&& !methodParameter.getMethod().getReturnType().isAssignableFrom(String.class)
&& !methodParameter.getMethod().getReturnType().isAssignableFrom(Result.class)
&& methodParameter.getDeclaringClass().getPackage().getName().contains(PACKAGE_PATH);
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
if (body instanceof Result) {
return body;
}
return new Result(ResultCode.OK, body);
}
}
方法supports
返回true
则会执行beforeBodyWrite
方法,否则会跳过,继续按照原来接口方法的返回值进行返回。
这里谈一下我过滤这些条件的想法:
- 返回值为 Void 的方法:返回值为void,嗯。
- 返回值为 String 的方法: 在
SpringMVC
中我们会返回字符串来匹配模板,虽然现在都是前后端分离的项目,但是还是按照约定俗成或者是第一反应,将String
排除。 - 返回
Result
的方法:我们已经做了封装,不需要在封装一次了。 - 不在指定包路径下的方法:用来规避其他组件,比如
Swagger
。
经过以上这种处理,我们在写接口的时候就可以放心大胆的定义业务需要的返回值了,真正的实现了接口方法就可以描述业务的初衷。
但是这样做会出现一个问题,比如Spring Data Jpa实体使用@JsonView时,接口就会返回空,不知道哪位路过的大神可以指导我一下。
除了这种方式,大家还可以通过过滤器来实现这个功能,这里就不做说明了。
SpringBoot + Vue 搭建前后端分离的博客项目系统(SpringBoot 部分)
SpringBoot + Vue 搭建前后端分离的博客项目系统
一:简单介绍
功能大纲
博客项目系统的基本增删改查
学习目的
搭建前后端分离项目的骨架
二:Java 后端接口开发
1、前言
- 从零开始搭建一个项目骨架,最好选择合适、熟悉的技术,并且在未来易拓展,适合微服务化体系等。所以一般以SpringBoot作为我们的框架基础。
- 然后数据层,我们常用的是Mybatis,易上手,方便维护。但是单表操作比较困难,特别是添加字段或减少字段的时候,比较繁琐,所以这里我推荐使用Mybatis Plus,为简化开发而生,只需简单配置,即可快速进行 CRUD 操作,从而节省大量时间。
- 作为一个项目骨架,权限也是我们不能忽略的,Shiro配置简单,使用也简单,所以使用Shiro作为我们的的权限。
- 考虑到项目可能需要部署多台,这时候我们的会话等信息需要共享,Redis是现在主流的缓存中间件,也适合我们的项目。
- 因为前后端分离,所以我们使用Jwt作为我们用户身份凭证。
2、技术栈
- SpringBoot
- Mybatis Plus
- Shiro
- Lombok
- Redis
- Hibernate Validatior
- Jwt
3、新建 SpringBoot 项目
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.pony</groupId>
<artifactId>springboot_blog</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>springboot_blog</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!--项目的热加载重启插件-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<!--mysql-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!--化代码的工具-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!--mybatis plus-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.2.0</version>
</dependency>
<!--mybatis plus 代码生成器-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>3.2.0</version>
</dependency>
<!--freemarker-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
application.yml
# DataSource Config
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/springboot_blog?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: password
mybatis-plus:
mapper-locations: classpath*:/mapper/**Mapper.xml
开启mapper接口扫描,添加分页插件
package com.pony.config;
import com.baomidou.mybatisplus.extension.plugins.PaginationInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.annotation.EnableTransactionManagement;
/**
* @author malf
* @description 通过@MapperScan 注解指定要变成实现类的接口所在的包,然后包下面的所有接口在编译之后都会生成相应的实现类。
* PaginationInterceptor是一个分页插件。
* @date 2021/5/22
* @project springboot_blog
*/
@Configuration
@EnableTransactionManagement
@MapperScan("com.pony.mapper")
public class MybatisPlusConfig {
@Bean
public PaginationInterceptor paginationInterceptor() {
PaginationInterceptor paginationInterceptor = new PaginationInterceptor();
return paginationInterceptor;
}
}
代码生成(直接根据数据库表信息生成entity、service、mapper等接口和实现类)
创建 Mysql 数据库表
CREATE TABLE `pony_user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `username` varchar(64) DEFAULT NULL, `avatar` varchar(255) DEFAULT NULL, `email` varchar(64) DEFAULT NULL, `password` varchar(64) DEFAULT NULL, `status` int(5) NOT NULL, `created` datetime DEFAULT NULL, `last_login` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `UK_USERNAME` (`username`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8; CREATE TABLE `pony_blog` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL, `title` varchar(255) NOT NULL, `description` varchar(255) NOT NULL, `content` longtext, `created` datetime NOT NULL ON UPDATE CURRENT_TIMESTAMP, `status` tinyint(4) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8mb4; INSERT INTO `springboot_blog`.`pony_user` (`id`, `username`, `avatar`, `email`, `password`, `status`, `created`, `last_login`) VALUES (''1'', ''pony'', ''https://image-1300566513.cos.ap-guangzhou.myqcloud.com/upload/images/5a9f48118166308daba8b6da7e466aab.jpg'', NULL, ''96e79218965eb72c92a549dd5a330112'', ''0'', ''2021-05-20 10:44:01'', NULL);
代码生成器:CodeGenerator
package com.pony; import com.baomidou.mybatisplus.core.exceptions.MybatisPlusException; import com.baomidou.mybatisplus.core.toolkit.StringPool; import com.baomidou.mybatisplus.core.toolkit.StringUtils; import com.baomidou.mybatisplus.generator.AutoGenerator; import com.baomidou.mybatisplus.generator.InjectionConfig; import com.baomidou.mybatisplus.generator.config.*; import com.baomidou.mybatisplus.generator.config.po.TableInfo; import com.baomidou.mybatisplus.generator.config.rules.NamingStrategy; import com.baomidou.mybatisplus.generator.engine.FreemarkerTemplateEngine; import java.util.ArrayList; import java.util.List; import java.util.Scanner; /** * @author malf * @description 执行 main 方法,在控制台输入模块表名回车自动生成对应项目目录 * @date 2021/5/22 * @project springboot_blog */ public class CodeGenerator { /** * 读取控制台内容 */ public static String scanner(String tip) { Scanner scanner = new Scanner(System.in); StringBuilder help = new StringBuilder(); help.append("请输入" + tip + ":"); System.out.println(help.toString()); if (scanner.hasNext()) { String ipt = scanner.next(); if (StringUtils.isNotEmpty(ipt)) { return ipt; } } throw new MybatisPlusException("请输入正确的" + tip + "!"); } public static void main(String[] args) { // 代码生成器 AutoGenerator mpg = new AutoGenerator(); // 全局配置 GlobalConfig gc = new GlobalConfig(); String projectPath = System.getProperty("user.dir"); gc.setOutputDir(projectPath + "/src/main/java"); gc.setAuthor("pony"); gc.setOpen(false); // gc.setSwagger2(true); 实体属性 Swagger2 注解 gc.setServiceName("%sService"); mpg.setGlobalConfig(gc); // 数据源配置 DataSourceConfig dsc = new DataSourceConfig(); dsc.setUrl("jdbc:mysql://localhost:3306/springboot_blog?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=UTC"); dsc.setDriverName("com.mysql.cj.jdbc.Driver"); dsc.setUsername("root"); dsc.setPassword("password"); mpg.setDataSource(dsc); // 包配置 PackageConfig pc = new PackageConfig(); pc.setModuleName(null); pc.setParent("com.pony"); mpg.setPackageInfo(pc); // 自定义配置 InjectionConfig cfg = new InjectionConfig() { @Override public void initMap() { } }; // 如果模板引擎是 freemarker String templatePath = "/templates/mapper.xml.ftl"; // 如果模板引擎是 velocity // String templatePath = "/templates/mapper.xml.vm"; // 自定义输出配置 List<FileOutConfig> focList = new ArrayList<>(); // 自定义配置会被优先输出 focList.add(new FileOutConfig(templatePath) { @Override public String outputFile(TableInfo tableInfo) { // 自定义输出文件名 , 如果 Entity 设置了前后缀,此处注意 xml 的名称会跟着发生变化 return projectPath + "/src/main/resources/mapper/" + "/" + tableInfo.getEntityName() + "Mapper" + StringPool.DOT_XML; } }); cfg.setFileOutConfigList(focList); mpg.setCfg(cfg); // 配置模板 TemplateConfig templateConfig = new TemplateConfig(); templateConfig.setXml(null); mpg.setTemplate(templateConfig); // 策略配置 StrategyConfig strategy = new StrategyConfig(); strategy.setNaming(NamingStrategy.underline_to_camel); strategy.setColumnNaming(NamingStrategy.underline_to_camel); strategy.setEntityLombokModel(true); strategy.setRestControllerStyle(true); strategy.setInclude(scanner("表名,多个英文逗号分割").split(",")); strategy.setControllerMappingHyphenStyle(true); strategy.setTablePrefix("pony_"); mpg.setStrategy(strategy); mpg.setTemplateEngine(new FreemarkerTemplateEngine()); mpg.execute(); } }
- 执行 main 方法,输入 pony_user, pony_blog后回车即可,代码目录结构如下:
测试当前步骤正确及数据库连接正常
package com.pony.controller; import com.pony.service.UserService; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import javax.annotation.Resource; /** * <p> * 前端控制器 * </p> * * @author pony * @since 2021-05-22 */ @RestController @RequestMapping("/user") public class UserController { @Resource UserService userService; @GetMapping("/{id}") public Object test(@PathVariable("id") Long id) { return userService.getById(id); } }
运行项目,访问地址
http://localhost:8080/user/1
4、统一结果封装
package com.pony.common; import lombok.Data; import java.io.Serializable; /** * @author malf * @description 用于异步统一返回的结果封装。 * @date 2021/5/22 * @project springboot_blog */ @Data public class Result implements Serializable { private String code; // 是否成功 private String message; // 结果消息 private Object data; // 结果数据 public static Result success(Object data) { Result result = new Result(); result.setCode("0"); result.setData(data); result.setMessage("操作成功"); return result; } public static Result success(String message, Object data) { Result result = new Result(); result.setCode("0"); result.setData(data); result.setMessage(message); return result; } public static Result fail(String message) { Result result = new Result(); result.setCode("-1"); result.setData(null); result.setMessage(message); return result; } public static Result fail(String message, Object data) { Result result = new Result(); result.setCode("-1"); result.setData(data); result.setMessage(message); return result; } }
5、整合Shiro + Jwt,并会话共享
考虑到后面可能需要做集群、负载均衡等,所以就需要会话共享,而Shiro的缓存和会话信息,我们一般考虑使用Redis来存储数据,所以,我们不仅需要整合Shiro,也需要整合Redis。
引入 shiro-redis 和 jwt 依赖,为了简化开发,同时引入hutool工具包。
<dependency> <groupId>org.crazycake</groupId> <artifactId>shiro-redis-spring-boot-starter</artifactId> <version>3.2.1</version> </dependency> <!-- hutool工具类--> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.3.3</version> </dependency> <!-- jwt --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>
编写Shiro配置类
第一步:生成和校验jwt的工具类,其中有些jwt相关的密钥信息是从项目配置文件中配置的
package com.pony.util; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; import java.util.Date; /** * @author malf * @description * @date 2021/5/22 * @project springboot_blog */ @Slf4j @Data @Component @ConfigurationProperties(prefix = "pony.jwt") public class JwtUtils { private String secret; private long expire; private String header; /** * 生成jwt token */ public String generateToken(long userId) { Date nowDate = new Date(); // 过期时间 Date expireDate = new Date(nowDate.getTime() + expire * 1000); return Jwts.builder() .setHeaderParam("typ", "JWT") .setSubject(userId + "") .setIssuedAt(nowDate) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS512, secret) .compact(); } public Claims getClaimByToken(String token) { try { return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); } catch (Exception e) { log.debug("validate is token error ", e); return null; } } /** * token是否过期 * * @return true:过期 */ public boolean isTokenExpired(Date expiration) { return expiration.before(new Date()); } }
第二步:自定义一个JwtToken,来完成shiro的supports方法
package com.pony.shiro; import org.apache.shiro.authc.AuthenticationToken; /** * @author malf * @description shiro默认supports的是UsernamePasswordToken,我们现在采用了jwt的方式, * 所以这里自定义一个JwtToken,来完成shiro的supports方法。 * @date 2021/5/22 * @project springboot_blog */ public class JwtToken implements AuthenticationToken { private String token; public JwtToken(String token) { this.token = token; } @Override public Object getPrincipal() { return token; } @Override public Object getCredentials() { return token; } }
第三步:登录成功之后返回的一个用户信息的载体
package com.pony.shiro; import lombok.Data; import java.io.Serializable; /** * @author malf * @description 登录成功之后返回的一个用户信息的载体 * @date 2021/5/22 * @project springboot_blog */ @Data public class AccountProfile implements Serializable { private Long id; private String username; private String avatar; }
第四步:shiro进行登录或者权限校验的逻辑所在
package com.pony.shiro; import cn.hutool.core.bean.BeanUtil; import com.pony.entity.User; import com.pony.service.UserService; import com.pony.util.JwtUtils; import lombok.extern.slf4j.Slf4j; import org.apache.shiro.authc.*; import org.apache.shiro.authz.AuthorizationInfo; import org.apache.shiro.realm.AuthorizingRealm; import org.apache.shiro.subject.PrincipalCollection; import org.springframework.stereotype.Component; import javax.annotation.Resource; /** * @author malf * @description shiro进行登录或者权限校验的逻辑所在 * @date 2021/5/22 * @project springboot_blog */ @Slf4j @Component public class AccountRealm extends AuthorizingRealm { @Resource JwtUtils jwtUtils; @Resource UserService userService; @Override public boolean supports(AuthenticationToken token) { return token instanceof JwtToken; } @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { return null; } @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { JwtToken jwt = (JwtToken) token; log.info("jwt----------------->{}", jwt); String userId = jwtUtils.getClaimByToken((String) jwt.getPrincipal()).getSubject(); User user = userService.getById(Long.parseLong(userId)); if (user == null) { throw new UnknownAccountException("账户不存在!"); } if (user.getStatus() == -1) { throw new LockedAccountException("账户已被锁定!"); } AccountProfile profile = new AccountProfile(); BeanUtil.copyProperties(user, profile); log.info("profile----------------->{}", profile.toString()); return new SimpleAuthenticationInfo(profile, jwt.getCredentials(), getName()); } }
- supports:为了让realm支持jwt的凭证校验
- doGetAuthorizationInfo:权限校验
- doGetAuthenticationInfo:登录认证校验,通过jwt获取到用户信息,判断用户的状态,最后异常就抛出对应的异常信息,否者封装成SimpleAuthenticationInfo返回给shiro。
第五步:定义jwt的过滤器JwtFilter
package com.pony.shiro; import cn.hutool.json.JSONUtil; import com.baomidou.mybatisplus.core.toolkit.StringUtils; import com.pony.common.Result; import com.pony.util.JwtUtils; import io.jsonwebtoken.Claims; import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.authc.ExpiredCredentialsException; import org.apache.shiro.web.filter.authc.AuthenticatingFilter; import org.apache.shiro.web.util.WebUtils; import org.springframework.web.bind.annotation.RequestMethod; import javax.annotation.Resource; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; /** * @author malf * @description 定义jwt的过滤器JwtFilter。 * @date 2021/5/22 * @project springboot_blog */ @Component public class JwtFilter extends AuthenticatingFilter { @Resource JwtUtils jwtUtils; @Override protected AuthenticationToken createToken(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception { // 获取 token HttpServletRequest request = (HttpServletRequest) servletRequest; String jwt = request.getHeader("Authorization"); if (StringUtils.isEmpty(jwt)) { return null; } return new JwtToken(jwt); } @Override protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception { HttpServletRequest request = (HttpServletRequest) servletRequest; String token = request.getHeader("Authorization"); if (StringUtils.isEmpty(token)) { return true; } else { // 判断是否已过期 Claims claim = jwtUtils.getClaimByToken(token); if (claim == null || jwtUtils.isTokenExpired(claim.getExpiration())) { throw new ExpiredCredentialsException("token已失效,请重新登录!"); } } // 执行自动登录 return executeLogin(servletRequest, servletResponse); } @Override protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) { HttpServletResponse httpResponse = (HttpServletResponse) response; try { //处理登录失败的异常 Throwable throwable = e.getCause() == null ? e : e.getCause(); Result r = Result.fail(throwable.getMessage()); String json = JSONUtil.toJsonStr(r); httpResponse.getWriter().print(json); } catch (IOException e1) { } return false; } /** * 对跨域提供支持 */ @Override protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception { HttpServletRequest httpServletRequest = WebUtils.toHttp(request); HttpServletResponse httpServletResponse = WebUtils.toHttp(response); httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin")); httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE"); httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers")); // 跨域时会首先发送一个OPTIONS请求,这里我们给OPTIONS请求直接返回正常状态 if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) { httpServletResponse.setStatus(org.springframework.http.HttpStatus.OK.value()); return false; } return super.preHandle(request, response); } }
- createToken:实现登录,我们需要生成我们自定义支持的JwtToken
- onAccessDenied:拦截校验,当头部没有Authorization时候,我们直接通过,不需要自动登录;当带有的时候,首先我们校验jwt的有效性,没问题我们就直接执行executeLogin方法实现自动登录
- onLoginFailure:登录异常时候进入的方法,我们直接把异常信息封装然后抛出
- preHandle:拦截器的前置拦截,因为是前后端分析项目,项目中除了需要跨域全局配置之外,在拦截器中也需要提供跨域支持。这样,拦截器才不会在进入Controller之前就被限制了
第六步:Shiro 配置类
package com.pony.config; import com.pony.shiro.AccountRealm; import com.pony.shiro.JwtFilter; import org.apache.shiro.mgt.DefaultSessionStorageEvaluator; import org.apache.shiro.mgt.DefaultSubjectDAO; import org.apache.shiro.mgt.SecurityManager; import org.apache.shiro.session.mgt.SessionManager; import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor; import org.apache.shiro.spring.web.ShiroFilterFactoryBean; import org.apache.shiro.spring.web.config.DefaultShiroFilterChainDefinition; import org.apache.shiro.spring.web.config.ShiroFilterChainDefinition; import org.apache.shiro.web.mgt.DefaultWebSecurityManager; import org.apache.shiro.web.session.mgt.DefaultWebSessionManager; import org.crazycake.shiro.RedisCacheManager; import org.crazycake.shiro.RedisSessionDAO; import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import javax.annotation.Resource; import javax.servlet.Filter; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map; /** * @author malf * @description shiro 启用注解拦截控制器 * @date 2021/5/22 * @project springboot_blog */ @Configuration public class ShiroConfig { @Resource JwtFilter jwtFilter; @Bean public SessionManager sessionManager(RedisSessionDAO redisSessionDAO) { DefaultWebSessionManager sessionManager = new DefaultWebSessionManager(); sessionManager.setSessionDAO(redisSessionDAO); return sessionManager; } @Bean public DefaultWebSecurityManager securityManager(AccountRealm accountRealm, SessionManager sessionManager, RedisCacheManager redisCacheManager) { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(accountRealm); securityManager.setSessionManager(sessionManager); securityManager.setCacheManager(redisCacheManager); /* * 关闭shiro自带的session,详情见文档 */ DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO(); DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator(); defaultSessionStorageEvaluator.setSessionStorageEnabled(false); subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator); securityManager.setSubjectDAO(subjectDAO); return securityManager; } @Bean public ShiroFilterChainDefinition shiroFilterChainDefinition() { DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition(); Map<String, String> filterMap = new LinkedHashMap<>(); filterMap.put("/**", "jwt"); // 主要通过注解方式校验权限 chainDefinition.addPathDefinitions(filterMap); return chainDefinition; } @Bean("shiroFilterFactoryBean") public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager, ShiroFilterChainDefinition shiroFilterChainDefinition) { ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean(); shiroFilter.setSecurityManager(securityManager); Map<String, Filter> filters = new HashMap<>(); filters.put("jwt", jwtFilter); shiroFilter.setFilters(filters); Map<String, String> filterMap = shiroFilterChainDefinition.getFilterChainMap(); shiroFilter.setFilterChainDefinitionMap(filterMap); return shiroFilter; } // 开启注解代理(默认好像已经开启,可以不要) @Bean public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) { AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor(); authorizationAttributeSourceAdvisor.setSecurityManager(securityManager); return authorizationAttributeSourceAdvisor; } @Bean public static DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator() { DefaultAdvisorAutoProxyCreator creator = new DefaultAdvisorAutoProxyCreator(); return creator; } }
- 引入RedisSessionDAO和RedisCacheManager,为了解决shiro的权限数据和会话信息能保存到redis中,实现会话共享。
- 重写了SessionManager和DefaultWebSecurityManager,同时在DefaultWebSecurityManager中为了关闭shiro自带的session方式,我们需要设置为false,这样用户就不再能通过session方式登录shiro。后面将采用jwt凭证登录。
- 在ShiroFilterChainDefinition中,我们不再通过编码形式拦截Controller访问路径,而是所有的路由都需要经过JwtFilter这个过滤器,然后判断请求头中是否含有jwt的信息,有就登录,没有就跳过。跳过之后,有Controller中的shiro注解进行再次拦截,比如@RequiresAuthentication,这样控制权限访问。
第七步:配置文件
shiro-redis: enabled: true redis-manager: host: 127.0.0.1:6379 pony: jwt: # 加密秘钥 secret: f4e2e52034348f86b67cde581c0f9eb5 # token有效时长,7天,单位秒 expire: 604800 header: token
第八步:热部署配置(如果添加了 devtools 依赖)
resources/META-INF/spring-devtools.propertiesrestart.include.shiro-redis=/shiro-[\\w-\\.]+jar
目前为止,shiro就已经完成整合进来了,并且使用了jwt进行身份校验。
6、异常处理
package com.pony;
import com.pony.common.Result;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.ShiroException;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindingResult;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.io.IOException;
/**
* @author malf
* @description 全局异常处理
* @ControllerAdvice表示定义全局控制器异常处理,@ExceptionHandler表示针对性异常处理,可对每种异常针对性处理。
* @date 2021/5/22
* @project springboot_blog
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
// 捕捉shiro的异常
@ResponseStatus(HttpStatus.UNAUTHORIZED)
@ExceptionHandler(ShiroException.class)
public Result handle401(ShiroException e) {
return Result.fail("401", e.getMessage(), null);
}
/**
* 处理Assert的异常
*/
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(value = IllegalArgumentException.class)
public Result handler(IllegalArgumentException e) throws IOException {
log.error("Assert异常:-------------->{}", e.getMessage());
return Result.fail(e.getMessage());
}
/**
* 校验错误异常处理
*/
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public Result handler(MethodArgumentNotValidException e) throws IOException {
log.error("运行时异常:-------------->", e);
BindingResult bindingResult = e.getBindingResult();
ObjectError objectError = bindingResult.getAllErrors().stream().findFirst().get();
return Result.fail(objectError.getDefaultMessage());
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(value = RuntimeException.class)
public Result handler(RuntimeException e) throws IOException {
log.error("运行时异常:-------------->", e);
return Result.fail(e.getMessage());
}
}
- ShiroException:shiro抛出的异常,比如没有权限,用户登录异常
- IllegalArgumentException:处理Assert的异常
- MethodArgumentNotValidException:处理实体校验的异常
RuntimeException:捕捉其他异常
7、实体校验
1、表单数据提交的时候,前端的校验可以使用一些类似于jQuery Validate等js插件实现,而后端可以使用Hibernate validatior来做校验。
@NotBlank(message = "昵称不能为空") private String username; private String avatar; @NotBlank(message = "邮箱不能为空") @Email(message = "邮箱格式不正确") private String email;
2、@Validated注解校验实体
/** * 测试实体校验 * @param user * @return */ @PostMapping("/save") public Object testUser(@Validated @RequestBody User user) { return user.toString(); }
8、跨域问题
package com.pony.config; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; /** * @author malf * @description 全局跨域处理 * @date 2021/5/22 * @project springboot_blog */ @Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS") .allowCredentials(true) .maxAge(3600) .allowedHeaders("*"); } }
9、登录接口开发
登录账号密码实体
package com.pony.common; import lombok.Data; import javax.validation.constraints.NotBlank; /** * @author malf * @description * @date 2021/5/22 * @project springboot_blog */ @Data public class LoginDto { @NotBlank(message = "昵称不能为空") private String username; @NotBlank(message = "密码不能为空") private String password; }
登录退出入口
package com.pony.controller; import cn.hutool.core.lang.Assert; import cn.hutool.core.map.MapUtil; import cn.hutool.crypto.SecureUtil; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.pony.common.LoginDto; import com.pony.common.Result; import com.pony.entity.User; import com.pony.service.UserService; import com.pony.util.JwtUtils; import org.apache.shiro.SecurityUtils; import org.apache.shiro.authz.annotation.RequiresAuthentication; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import javax.annotation.Resource; import javax.servlet.http.HttpServletResponse; /** * @author malf * @description 登录接口 * 接受账号密码,然后把用户的id生成jwt,返回给前段,为了后续的jwt的延期,把jwt放在header上 * @date 2021/5/22 * @project springboot_blog */ public class AccountController { @Resource JwtUtils jwtUtils; @Resource UserService userService; /** * 默认账号密码:pony / 111111 */ @CrossOrigin @PostMapping("/login") public Result login(@Validated @RequestBody LoginDto loginDto, HttpServletResponse response) { User user = userService.getOne(new QueryWrapper<User>().eq("username", loginDto.getUsername())); Assert.notNull(user, "用户不存在"); if (!user.getPassword().equals(SecureUtil.md5(loginDto.getPassword()))) { return Result.fail("密码错误!"); } String jwt = jwtUtils.generateToken(user.getId()); response.setHeader("Authorization", jwt); response.setHeader("Access-Control-Expose-Headers", "Authorization"); // 用户可以另一个接口 return Result.success(MapUtil.builder() .put("id", user.getId()) .put("username", user.getUsername()) .put("avatar", user.getAvatar()) .put("email", user.getEmail()) .map() ); } // 退出 @GetMapping("/logout") @RequiresAuthentication public Result logout() { SecurityUtils.getSubject().logout(); return Result.success(null); } }
登录接口测试
10、博客接口开发
ShiroUtils
package com.pony.util; import com.pony.shiro.AccountProfile; import org.apache.shiro.SecurityUtils; /** * @author malf * @description * @date 2021/5/22 * @project springboot_blog */ public class ShiroUtils { public static AccountProfile getProfile() { return (AccountProfile) SecurityUtils.getSubject().getPrincipal(); } }
博客操作入口
package com.pony.controller; import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.lang.Assert; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.pony.common.Result; import com.pony.entity.Blog; import com.pony.service.BlogService; import com.pony.util.ShiroUtils; import org.apache.shiro.authz.annotation.RequiresAuthentication; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; import java.time.LocalDateTime; /** * <p> * 前端控制器 * </p> * * @author pony * @since 2021-05-22 */ @RestController public class BlogController { @Resource BlogService blogService; @GetMapping("/blogs") public Result blogs(Integer currentPage) { if (currentPage == null || currentPage < 1) currentPage = 1; Page page = new Page(currentPage, 5); IPage pageData = blogService.page(page, new QueryWrapper<Blog>().orderByDesc("created")); return Result.success(pageData); } @GetMapping("/blog/{id}") public Result detail(@PathVariable(name = "id") Long id) { Blog blog = blogService.getById(id); Assert.notNull(blog, "该博客已删除!"); return Result.success(blog); } @RequiresAuthentication @PostMapping("/blog/edit") public Result edit(@Validated @RequestBody Blog blog) { System.out.println(blog.toString()); Blog temp = null; if (blog.getId() != null) { temp = blogService.getById(blog.getId()); Assert.isTrue(temp.getUserId() == ShiroUtils.getProfile().getId(), "没有权限编辑"); } else { temp = new Blog(); temp.setUserId(ShiroUtils.getProfile().getId()); temp.setCreated(LocalDateTime.now()); temp.setStatus(0); } BeanUtil.copyProperties(blog, temp, "id", "userId", "created", "status"); blogService.saveOrUpdate(temp); return Result.success("操作成功", null); } }
接口测试
目前为止,后端接口的开发基本完成。源码参考
springboot_blog
springboot 中后端返回数据给前端的设计模式
后端数据都是以 json 的形式给前端返回,一般都是通过 @ResponseBody 来进行返回。每次返回都要重新格式化一下 json 的格式。如果接受请求的方法多了,那么这种返回方式无疑是效率很低的。那么就需要一个类来吧这些返回的格式统一管理起来.
可以定义一个 Result 类,用来专门管理返回数据的:
@Data
public class Result<T> {
private int code; //返回状态码
private String msg; //返回的message
private T data; //返回的数据
private Result(T data) {
this.code = 0;
this.msg = "success";
this.data = data;
}
private Result(CodeMsg msg) {
if (msg == null) {
return;
}
this.code = msg.getCode();
this.msg = msg.getMsg();
}
/**
* 失败的时候调用
*/
public static <T> Result<T> error(CodeMsg msg) {
return new Result<T>(msg);
}
/**
*成功的时候调用
*/
public static <T> Result<T> success(T data) {
return new Result<T>(data);
}
}
其中 CodeMsg 封装了具体的错误信息:
@Data
public class CodeMsg {
private int code;
private String msg;
private CodeMsg(int code, String msg) {
this.code = code;
this.msg = msg;
}
public CodeMsg fillArgs(Object... args) { //带自定义格式化参数的错误信息
int code = this.code;
String message = String.format(this.msg, args);
return new CodeMsg(code,message);
}
/**
* 通用异常
*/
public static CodeMsg SUCCESS = new CodeMsg(0, "SUCESS");
public static CodeMsg SERVER_ERROR = new CodeMsg(500100, "服务端异常");
public static CodeMsg BIND_ERROR = new CodeMsg(500101, "参数校验异常:%s");
/**
* 登录模块5002XX
*/
public static final CodeMsg PASSWORD_EMPTY = new CodeMsg(500211, "密码不能为空");
public static final CodeMsg MOBILE_EMPTY = new CodeMsg(500211, "手机号不能为空");
public static final CodeMsg MOBILE_ERROR = new CodeMsg(500211, "手机号格式错误");
public static final CodeMsg USER_NOT_EXITS = new CodeMsg(500211, "用户不存在");
public static final CodeMsg PASSWORD_ERROR = new CodeMsg(500211, "密码错误");
}
这样在后端返回的时候,只需要给定 CodeMsg 里面的静态常量就可以了。根据自己需要进行定义.
return Result.success(true);
return Result.error(CodeMsg.SERVER_ERROR)
今天关于SpringBoot 如何统一后端返回格式?老鸟们都是这样玩的!和springboot返回页面和数据的介绍到此结束,谢谢您的阅读,有关Spring Boot 中如何统一 API 接口响应格式?、Spring Boot 接口封装统一返回格式、SpringBoot + Vue 搭建前后端分离的博客项目系统(SpringBoot 部分)、springboot 中后端返回数据给前端的设计模式等更多相关知识的信息可以在本站进行查询。
本文标签: