整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

Spring Boot 异常处理

Spring Boot 异常处理

. 前言

程序中出现异常是普遍现象, Java 程序员想必早已习惯,根据控制台输出的异常信息,分析异常产生的原因,然后进行针对性处理的过程。

Spring Boot 项目中,数据持久层、服务层到控制器层都可能抛出异常。如果我们在各层都进行异常处理,程序代码会显得支离破碎,难以理解。

实际上,异常可以从内层向外层不断抛出,最后在控制器层进行统一处理。 Spring Boot 提供了全局性的异常处理机制,本节我们就分别演示下,默认情况、控制器返回视图、控制器返回 JSON 数据三种情况的异常处理方法。

2. Spring Boot 默认异常处理机制

Spring Boot 开发的 Web 项目具备默认的异常处理机制,无须编写异常处理相关代码,即可提供默认异常机制,下面具体演示下。

2.1 使用 Spring Initializr 创建项目

Spring Boot 版本选择 2.2.5 ,Group 为 com.imooc , Artifact 为 spring-boot-exception-default ,生成项目后导入 Eclipse 开发环境。

2.2 引入项目依赖

引入 Web 项目依赖即可。

实例:

		<!-- web项目依赖 -->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>

2.3 Spring Boot 默认异常处理

我们在启动项目, Spring Boot Web 项目默认启动端口为 8080 ,所以直接访问 http://127.0.0.1:8080 ,显示如下:

Spring Boot 默认异常信息提示页面

如上图所示,Spring Boot 默认的异常处理机制生效,当出现异常时会自动转向 /error 路径。

3. 控制器返回视图时的异常处理

在使用模板引擎开发 Spring Boot Web 项目时,控制器会返回视图页面。我们使用 Thymeleaf 演示控制器返回视图时的异常处理方式,其他模板引擎处理方式也是相似的。

3.1 使用 Spring Initializr 创建项目

Spring Boot 版本选择 2.2.5 ,Group 为 com.imooc , Artifact 为 spring-boot-exception-controller,生成项目后导入 Eclipse 开发环境。

3.2 引入项目依赖

引入 Web 项目依赖、热部署依赖。此处使用 Thymeleaf 演示控制器返回视图时的异常处理方式,所以引入 Thymeleaf 依赖。

实例:

		<!-- web项目依赖 -->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<!-- 热部署 -->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-devtools</artifactId>
		</dependency>
		<!-- ThymeLeaf依赖 -->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-thymeleaf</artifactId>
		</dependency>

3.3 定义异常类

在异常处理之前,我们应该根据业务场景具体情况,定义一系列的异常类,习惯性的还会为各种异常分配错误码,如下图为支付宝开放平台的公共错误码信息。

支付宝开放平台错误码

本节我们为了演示,简单的定义 2 个异常类,包含错误码及错误提示信息。

实例:

/**
 * 自定义异常
 */
public class BaseException extends Exception {
	/**
	 * 错误码
	 */
	private int code;
	/**
	 * 错误提示信息
	 */
	private String msg;

	public BaseException(int code, String msg) {
		super();
		this.code=code;
		this.msg=msg;
	}
	// 省略get set
}
代码块1234567891011121314151617181920

实例:

/**
 * 密码错误异常
 */
public class PasswordException extends BaseException {
	public PasswordException() {
		super(10001, "密码错误");
	}
}

实例:

/**
 * 验证码错误异常
 */
public class VerificationCodeException extends BaseException {
	public VerificationCodeException() {
		super(10002, "验证码错误");
	}
}

3.4 控制器抛出异常

定义控制器 GoodsController ,然后使用注解 @Controller 标注该类,类中方法的返回值即为视图文件名。

在 GoodsController 类定义 4 个方法,分别用于正常访问、抛出密码错误异常、抛出验证码错误异常、抛出未自定义的异常,代码如下。

实例:

/**
 * 商品控制器
 */
@Controller
public class GoodsController {
	/**
	 * 正常方法
	 */
	@RequestMapping("/goods")
	public String goods() {
		return "goods";// 跳转到resource/templates/goods.html页面
	}

	/**
	 * 抛出密码错误异常的方法
	 */
	@RequestMapping("/checkPassword")
	public String checkPassword() throws PasswordException {
		if (true) {
			throw new PasswordException();// 模拟抛出异常,便于测试
		}
		return "goods";
	}

	/**
	 * 抛出验证码错误异常的方法
	 */
	@RequestMapping("/checkVerification")
	public String checkVerification() throws VerificationCodeException {
		if (true) {
			throw new VerificationCodeException();// 模拟抛出异常,便于测试
		}
		return "goods";
	}

	/**
	 * 抛出未自定义的异常
	 */
	@RequestMapping("/other")
	public String other() throws Exception {
		int a=1 / 0;// 模拟异常
		return "goods";
	}
}

3.5 开发基于 @ControllerAdvice 的全局异常类

@ControllerAdvice 注解标注的类可以处理 @Controller 标注的控制器类抛出的异常,然后进行统一处理。

实例:

/**
 * 控制器异常处理类
 */
@ControllerAdvice(annotations=Controller.class) // 全局异常处理
public class ControllerExceptionHandler {
	@ExceptionHandler({ BaseException.class }) // 当发生BaseException类(及其子类)的异常时,进入该方法
	public ModelAndView baseExceptionHandler(BaseException e) {
		ModelAndView mv=new ModelAndView();
		mv.addObject("code", e.getCode());
		mv.addObject("message", e.getMessage());
		mv.setViewName("myerror");// 跳转到resource/templates/myerror.html页面
		return mv;
	}

	@ExceptionHandler({ Exception.class }) // 当发生Exception类的异常时,进入该方法
	public ModelAndView exceptionHandler(Exception e) {
		ModelAndView mv=new ModelAndView();
		mv.addObject("code", 99999);// 其他异常统一编码为99999
		mv.addObject("message", e.getMessage());
		mv.setViewName("myerror");// 跳转到resource/templates/myerror.html页面
		return mv;
	}
}

按照 ControllerExceptionHandler 类的处理逻辑,当发生 BaseException 类型的异常时,会跳转到 myerror.html 页面,并显示相应的错误码和错误信息;当发生其他类型的异常时,错误码为 99999 ,错误信息为相关的异常信息。

3.6 开发前端页面

在 resource/templates 下分别新建 goods.html 和 myerror.html 页面,作为正常访问及发生异常时跳转的视图页面。

实例:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>goods.html页面</title>
</head>
<body>
	<div>商品信息页面</div>
</body>
</html>

实例:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>myerror.html页面</title>
</head>
<body>
	错误码:
	<span th:text="${code}"></span> 
	错误信息:
	<span th:text="${message}"></span>
</body>
</html>
代码块12345678910111213

3.7 测试

启动项目,分别访问控制器中的 4 个方法,结果如下:

访问正常方法 /goods

访问抛出自定义异常的方法 /checkPassword

访问抛出自定义异常的方法 /checkVerification

访问抛出未自定义异常的方法 /other

可见,当控制器方法抛出异常时,会按照全局异常类设定的逻辑统一处理。

4. 控制器返回 JSON 数据时的异常处理

在控制器类上添加 @RestController 注解,控制器方法处理完毕后会返回 JSON 格式的数据。

此时,可以使用 @RestControllerAdvice 注解标注的类 ,来捕获 @RestController 标注的控制器抛出的异常。

4.1 使用 Spring Initializr 创建项目

Spring Boot 版本选择 2.2.5 ,Group 为 com.imooc , Artifact 为 spring-boot-exception-restcontroller,生成项目后导入 Eclipse 开发环境。

4.2 引入项目依赖

引入 Web 项目依赖、热部署依赖即可。

实例:

		<!-- web项目依赖 -->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<!-- 热部署 -->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-devtools</artifactId>
		</dependency>

4.3 定义异常类

还是使用上文中定义的异常类即可。

4.4 统一控制器返回数据格式

这时候,我们就需要思考一个问题了。前端请求后端控制器接口后,怎么区分后端接口是正常返回结果,还是发生了异常?

不论后端接口是正常执行,还是中间发生了异常,最好给前端返回统一的数据格式,便于前端统一分析处理。

OK,此时我们就可以封装后端接口返回的业务逻辑对象 ResultBo ,代码如下:

实例:

/**
 * 后端接口返回的统一业务逻辑对象
 */
public class ResultBo<T> {

	/**
	 * 错误码 0表示没有错误(异常) 其他数字代表具体错误码
	 */
	private int code;
	/**
	 * 后端返回消息
	 */
	private String msg;
	/**
	 * 后端返回的数据
	 */
	private T data;

	/**
	 * 无参数构造函数
	 */
	public ResultBo() {
		this.code=0;
		this.msg="操作成功";
	}

	/**
	 * 带数据data构造函数
	 */
	public ResultBo(T data) {
		this();
		this.data=data;
	}

	/**
	 * 存在异常的构造函数
	 */
	public ResultBo(Exception ex) {
		if (ex instanceof BaseException) {
			this.code=((BaseException) ex).getCode();
			this.msg=ex.getMessage();
		} else {
			this.code=99999;// 其他未定义异常
			this.msg=ex.getMessage();
		}
	}
	// 省略 get set
}

4.5 控制器抛出异常

定义控制器 RestGoodsController ,并使用 @RestController 注解标注。在其中定义 4 个方法,然后分别用于正常访问、抛出密码错误异常、抛出验证码错误异常,以及抛出不属于自定义异常类的异常。

实例:

/**
 * Rest商品控制器
 */
@RestController
public class RestGoodsController {
	/**
	 * 正常方法
	 */
	@RequestMapping("/goods")
	public ResultBo goods() {
		return new ResultBo<>(new ArrayList());// 正常情况下应该返回商品列表
	}

	/**
	 * 抛出密码错误异常的方法
	 */
	@RequestMapping("/checkPassword")
	public ResultBo checkPassword() throws PasswordException {
		if (true) {
			throw new PasswordException();// 模拟抛出异常,便于测试
		}
		return new ResultBo<>(true);// 正常情况下应该返回检查密码的结果true或false
	}

	/**
	 * 抛出验证码错误异常的方法
	 */
	@RequestMapping("/checkVerification")
	public ResultBo checkVerification() throws VerificationCodeException {
		if (true) {
			throw new VerificationCodeException();// 模拟抛出异常,便于测试
		}
		return new ResultBo<>(true);// 正常情况下应该返回检查验证码的结果true或false
	}

	/**
	 * 抛出未自定义的异常
	 */
	@RequestMapping("/other")
	public ResultBo other() throws Exception {
		int a=1 / 0;// 模拟异常
		return new ResultBo<>(true);
	}
}

4.6 开发基于 @RestControllerAdvice 的全局异常类

@RestControllerAdvice 注解标注的类可以处理 RestController 控制器类抛出的异常,然后进行统一处理。

实例:

/**
 * Rest控制器异常处理类
 */
@RestControllerAdvice(annotations=RestController.class) // 全局异常处理
public class RestControllerExceptionHandler {
	/**
	 * 处理BaseException类(及其子类)的异常
	 */
	@ExceptionHandler({ BaseException.class })
	public ResultBo baseExceptionHandler(BaseException e) {
		return new ResultBo(e);
	}

	/**
	 * 处理Exception类的异常
	 */
	@ExceptionHandler({ Exception.class })
	public ResultBo exceptionHandler(Exception e) {
		return new ResultBo(e);
	}
}

4.7 测试

启动项目,分别尝试访问控制器中的 4 个接口,结果如下。

访问正常方法 /goods

访问抛出异常的方法 /checkPassword

访问抛出异常的方法 /checkVerification

访问抛出异常的方法 /other

5. 小结

Spring Boot 的默认异常处理机制,实际上只能做到提醒开发者 “这个后端接口不存在” 的作用,作用非常有限。

所以我们在开发 Spring Boot 项目时,需要根据项目的实际情况,定义各类异常,并站在全局的角度统一处理异常。

不管项目有多少层次,所有异常都可以向外抛出,直到控制器层进行集中处理。

  • 对于返回视图的控制器,如果没发生异常就跳转正常页面,如果发生异常可以自定义错误信息页面。
  • 对于返回 JSON 数据的控制器,最好是定义统一的数据返回格式,便于前端根据返回信息进行正常或者异常情况的处理。

于数千个项目,通过收集和分析大量数据选出了 10 大 JavaScript 错误,对其产生的根源,以及如何避免再发生这些错误进行剖析。实际开发中,如果能够避免这些错误,就可以成为更好的开发者。

这里只关注影响面最大的那些错误。为此,我们统计了错误在各个公司的项目中发生的次数,而不是错误发生的总次数,因为如果是这样的话,读者就可能看到大量与他们不相干的统计信息。

以下是排名靠前的 10 大 JavaScript 错误:

出于可读性方面的考虑,每个错误的描述经过精简。

1.Uncaught TypeError: Cannot read property

如果你是一名 JavaScript 开发者,对这个错误可能已经熟视无睹。在 Chrome 里读取未定义对象的属性或调用未定义对象的方法时就会发生这个错误,在 Chrome 开发者控制台可以很容易地重现这个错误。

发生这个错误的原因有很多,其中最为常见的是,在渲染 UI 组件时没有正确初始化状态。我们通过一个真实的例子来看看这个错误是怎么发生的。我们选择 React 作为示例,不过在其他框架(Angular、Vue 等)中也是一样的。

class Quiz extends Component {
componentWillMount() {
 axios.get('/thedata').then(res=> {
 this.setState({items: res.data});
 });
}
render() {
 return (
 <ul>
 {this.state.items.map(item=>
 <li key={item.id}>{item.name}</li>
 )}
 </ul>
 );
}
}

这里要注意两件事:

  • 组件的状态(如 this.state)在一开始就是 undefined。

  • 如果是通过异步的方式来加载数据,那么在数据加载进来之前,至少要渲染一次组件——不管是在构造器、componentWillMout() 还是 componentDidMout() 中加载数据。Quiz 在进行第一次渲染时,this.state.items 是 undefined,那么 ItemList 就会得到 undefined 的数据项,这样就会在控制台看到这个错误——“Uncaught TypeError:Cannot read property ‘map’ of undefined”。

要解决这个问题其实很简单,在构造器里使用适当的默认值进行初始化。

class Quiz extends Component {
// 增加这个:
constructor(props) {
 super(props);
 // 使用空数组给 state 赋值
 this.state={
 items: []
 };
}
componentWillMount() {
 axios.get('/thedata').then(res=> {
 this.setState({items: res.data});
 });
}
render() {
 return (
 <ul>
 {this.state.items.map(item=>
 <li key={item.id}>{item.name}</li>
 )}
 </ul>
 );
}
}

2. TypeError: ’undefined’ is not an object

在 Safari 里读取未定义对象的属性或调用未定义对象的方法时就会发生这个错误,在 Safari 开发者控制台可以很容易地重现这个错误。这个错误与发生在 Chrome 里的是差不多的,只是 Safari 为它提供了不同的错误信息。

3. TypeError: null is not an object

在 Safari 里读取空(null)对象的属性或调用空对象的方法时就会发生这个错误,在 Safari 开发者控制台可以很容易地重现这个错误。

有意思的是,在 JavaScript 里,null 和 undefined 其实是不一样的,所以我们会看到两个不同的错误消息。undefined 表示未赋值的变量,而 null 表示变量值为空。可以使用严格等于号来证明它们不是同一个东西。

在实际应用当中,在 JavaScript 里调用一个未加载的 DOM 元素就会出现这个错误。如果对象为空,DOM API 就会返回 null。

DOM 元素需要在创建之后才能被访问。JavaScript 代码是按照从上到下的顺序进行解析的,所以,如果在 DOM 元素之前有一个标签包含了 JavaScript 代码,浏览器在解析 HTML 时就会执行这些代码。在加载 JavaScript 之前,如果 DOM 元素没有被创建,就会出现这个错误。

在这个例子里,我们可以通过添加一个事件监听器来解决这个问题,在页面加载完毕时,事件监听器会通知我们。在 addEventListener 被触发之后,init() 方法就可以大胆地访问 DOM 元素了。

<script>
function init() {
 var myButton=document.getElementById("myButton");
 var myTextfield=document.getElementById("myTextfield");
 myButton.onclick=function() {
 var userName=myTextfield.value;
 }
}
document.addEventListener('readystatechange', function() {
 if (document.readyState==="complete") {
 init();
 }
});
</script>
<form>
<input type="text" id="myTextfield" placeholder="Type your name" />
<input type="button" id="myButton" value="Go" />
</form>

4. (unknown): Script error

跨域的未捕捉 JavaScript 异常会变成 Script error。例如,假设 JavaScript 托管在 CDN 上,那么未捕捉的错误(错误没有在 try-catch 里被捕获,一路直上到了 window.onerror 里)就会显示成“Script error”,而不是显示具体的错误消息。这是浏览器出于安全方面的考虑,防止跨域传递数据。

要想获得具体的错误信息,可以这样做:

1). 使用 Access-Control-Allow-Origin

将 Access-Control-Allow-Origin 设置成“*”,表示该资源可以被任何一个域访问。如果有必要,可以把“*”替换成你的域名,例如 Access-Control-Allow-Origin: www.example.com。不过,如果使用了 CDN,那么要支持多个域名可能就会得不偿失,因为 CDN 存在缓存问题。

下面是在各种环境如何设置该字段的示例:

Apache:

在 JavaScript 文件所在的目录创建一个叫作.htaccess 的文件,并加入如下内容:

Header add Access-Control-Allow-Origin "*"

Nginx:

在 JavaScript 对应的 location 配置代码块中加入 add_header 指令:

location ~ ^/assets/ {
 add_header Access-Control-Allow-Origin *;
}

HAProxy:

在 JavaScript 文件对应的 backend 配置块中加入如下内容:

rspadd Access-Control-Allow-Origin:\ *

2). 在 script 标签里设置 crossorigin=“anonymous”

在每个设置了 Access-Control-Allow-Origin 字段的 HTML 页面里,将它们的 script 标签的 crossorigin 属性设置为“anonymous”。在 Firefox 里,如果出现了 crossorigin,但没有设置 Access-Control-Allow-Origin,JavaScript 脚本就不会被执行。

5. TypeError: Object doesn’t support property

在 IE 里读取未定义对象的属性或调用未定义对象的方法时就会发生这个错误,在 IE 开发者控制台可以很容易地重现这个错误。

这个错误与 Chrome 里的“TypeError: ‘undefined’ is not a function”是同一个东西。不同的浏览器为相同的错误提供的错误消息可能是不一样的。

在 IE 里使用 JavaScript 的命名空间时,就很容易碰到这个错误。发生这个错误十有八九是因为 IE 无法将当前命名空间里的方法绑定到 this 关键字上。例如,假设有个命名空间 Rollbar,它有一个方法叫 isAwesome()。在 Rollbar 命名空间中,可以直接使用 this 关键字来调用这个方法:

this.isAwesome();

在 Chrome、Firefox 和 Opera 中这样做都是没有问题的,但在 IE 中就不行。所以,最安全的做法是指定全命名空间:

Rollbar.isAwesome();

6. TypeError: ‘undefined’ is not a function

在 Chrome 里调用一个未定义的函数时就会发生这个错误,可以在 Chrome 开发者控制台和 Mozilla 开发者控制台重现这个错误。

近年来,JavaScript 的编码技术和设计模式变得日趋复杂,回调和闭包中的自引用情况越来越普遍,让人搞不清楚代码中的 this/that 表示的是什么意思。

比如下面这段代码:

function testFunction() {
this.clearLocalStorage();
this.timer=setTimeout(function() {
 this.clearBoard(); // 这里的”this"是指什么?
}, 0);
};

执行上面的代码会出现这样的错误:“Uncaught TypeError: undefined is not a function”。因为在调用 setTimeout() 方法时,实际上是在调用 window.setTimeout()。传给 setTimeout() 的匿名函数的上下文实际上是 window,而 window 并不包含 clearBoard() 方法。

对于旧浏览器,以往的解决办法是将 this 赋值给某个变量,然后在闭包里使用这个变量。例如:

function testFunction () {
this.clearLocalStorage();
var self=this; // 将 this 赋值给 self
this.timer=setTimeout(function(){
 self.clearBoard(); 
}, 0);
};

在新浏览器中,可以使用 bind() 方法来传递引用:

function testFunction () {
this.clearLocalStorage();
this.timer=setTimeout(this.reset.bind(this), 0); // 绑定到 'this'
};
function testFunction(){
 this.clearBoard(); // 以’this’作为上下文
};

7. Uncaught RangeError: Maximum call stack

在 Chrome 里,有几种情况会发生这个错误,其中一个就是无限递归调用一个函数。这个错误可以在 Chrome 开发者控制台重现。

当传给函数的值超出可接受的范围时也会出现这个错误。很多函数只接受指定范围的数值,例如,Number.toExponential(digits) 和 Number.toFixed(digits) 只接受 0 到 20 的数值,而 Number.toPrecision(digits) 只接受 1 到 21 的数值。

var a=new Array(4294967295); //OK
var b=new Array(-1); //range error
var num=2.555555;
document.writeln(num.toExponential(4)); //OK
document.writeln(num.toExponential(-2)); //range error!
num=2.9999;
document.writeln(num.toFixed(2)); //OK
document.writeln(num.toFixed(25)); //range error!
num=2.3456;
document.writeln(num.toPrecision(1)); //OK
document.writeln(num.toPrecision(22)); //range error!

8. TypeError: Cannot read property ‘length’

在 Chrome 里读取 undefined 变量的 length 属性时会发生这个错误,这个错误可以在 Chrome 开发者控制台重现。

length 是数组的属性,但如果数组没有初始化或者数组的变量名被另一个上下文隐藏起来的话,访问 length 属性就会发生这个错误。例如:

var testArray=["Test"];
function testFunction(testArray) {
 for (var i=0; i < testArray.length; i++) {
 console.log(testArray[i]);
 }
}
testFunction();

函数的参数名会覆盖全局的变量名。也就是说,全局的 testArray 被函数的参数名覆盖了,所以在函数体里访问到的是本地的 testArray,但本地并没有定义 testArray,所以出现了这个错误。

有两种方法可用于解决这个问题:

1). 将函数的参数名移除(这就表示函数里要访问的变量已经在函数外面定义好了,所以函数不需要参数):

var testArray=["Test"];
/* 前提是要在函数外面定义好 testArray */
function testFunction(/* No params */) {
 for (var i=0; i < testArray.length; i++) {
 console.log(testArray[i]);
 }
}
testFunction();

2). 在调用函数时将变量传递进去:

var testArray=["Test"];
function testFunction(testArray) {
 for (var i=0; i < testArray.length; i++) {
 console.log(testArray[i]);
 }
}
testFunction(testArray);

9. Uncaught TypeError: Cannot set property

我们无法对 undefined 变量进行赋值或读取操作,否则的话会抛出“Uncaught TypeError: cannot set property of undefined”异常。

例如,在 Chrome 中:

如果 test 对象不存在,就会抛出“Uncaught TypeError: cannot set property of undefined”异常。

10. ReferenceError: event is not defined

在访问一个未定义的对象或超出当前作用域的对象时就会发生这个错误,这个错误可以在 Chrome 开发者控制台重现。

如果在进行事件处理时遇到这个错误,请确保事件对象被作为参数传入到函数当中。旧浏览器(IE)提供了全局的 event 变量,但并不是所有的浏览器都会这样。尽管 jQuery 尝试对这种行为进行规范化,但最好还是使用传给函数的 event 对象:

function myFunction(event) {
 event=event.which || event.keyCode;
 if(event.keyCode===13){
 alert(event.keyCode);
 }
}

结论

我们希望这些内容能够帮助大家在未来避免这些错误,解决大家的痛点。不过,即使有了这些最佳实践,在生产环境中仍然会出现各种不可预期的错误。关键是要及时发现那些影响用户体验的错误,并使用适当的工具快速解决这些问题。

原文(英文)地址 | https://rollbar.com/blog/top-10-javascript-errors/

质文章,及时送达

作者 | 嘟嘟MD

来源 | tengj.top/2018/05/16/springboot13

前言

今天来一起学习一下Spring Boot中的异常处理,在日常web开发中发生了异常,往往是需要通过一个统一的异常处理来保证客户端能够收到友好的提示。

正文

本篇要点如下:

介绍Spring Boot默认的异常处理机制

如何自定义错误页面

通过@ControllerAdvice注解来处理异常

介绍Spring Boot默认的异常处理机制

默认情况下,Spring Boot为两种情况提供了不同的响应方式。

一种是浏览器客户端请求一个不存在的页面或服务端处理发生异常时,一般情况下浏览器默认发送的请求头中Accept: text/html,所以Spring Boot默认会响应一个html文档内容,称作“Whitelabel Error Page”

另一种是使用Postman等调试工具发送请求一个不存在的url或服务端处理发生异常时,Spring Boot会返回类似如下的Json格式字符串信息

{
"timestamp": "2018-05-12T06:11:45.209+0000",
"status": 404,
"error": "Not Found",
"message": "No message available",
"path": "/index.html"
}

原理也很简单,Spring Boot 默认提供了程序出错的结果映射路径/error。这个/error请求会在BasicErrorController中处理,其内部是通过判断请求头中的Accept的内容是否为text/html来区分请求是来自客户端浏览器(浏览器通常默认自动发送请求头内容Accept:text/html)还是客户端接口的调用,以此来决定返回页面视图还是 JSON 消息内容。

相关BasicErrorController中代码如下:

如何自定义错误页面

好了,了解完Spring Boot默认的错误机制后,我们来点有意思的,浏览器端访问的话,任何错误Spring Boot返回的都是一个Whitelabel Error Page的错误页面,这个很不友好,所以我们可以自定义下错误页面。

1、先从最简单的开始,直接在/resources/templates下面创建error.html就可以覆盖默认的Whitelabel Error Page的错误页面,我项目用的是thymeleaf模板,对应的error.html代码如下:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
动态error错误页面
<p th:text="${error}"></p>
<p th:text="${status}"></p>
<p th:text="${message}"></p>
</body>
</html>

这样运行的时候,请求一个不存在的页面或服务端处理发生异常时,展示的自定义错误界面如下:

2、此外,如果你想更精细一点,根据不同的状态码返回不同的视图页面,也就是对应的404,500等页面,这里分两种,错误页面可以是静态HTML(即,添加到任何静态资源文件夹下),也可以使用模板构建,文件的名称应该是确切的状态码。

如果只是静态HTML页面,不带错误信息的,在resources/public/下面创建error目录,在error目录下面创建对应的状态码html即可 ,例如,要将404映射到静态HTML文件,您的文件夹结构如下所示:

静态404.html简单页面如下:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
静态404错误页面
</body>
</html>

这样访问一个错误路径的时候,就会显示静态404错误页面错误页面

注:这时候如果存在上面第一种介绍的error.html页面,则状态码错误页面将覆盖error.html,具体状态码错误页面优先级比较高。


如果是动态模板页面,可以带上错误信息,在resources/templates/下面创建error目录,在error目录下面命名即可:

这里我们模拟下500错误,控制层代码,模拟一个除0的错误:

@Controller 
publicclassBaseErrorControllerextendsAbstractController{
private Logger logger=LoggerFactory.getLogger(this.getClass);

@RequestMapping(value="/ex")
@ResponseBody
public String error{
int i=5/0;
return "ex";
}
}

500.html代码:

<!DOCTYPE html> 
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
动态500错误页面
<p th:text="${error}"></p>
<p th:text="${status}"></p>
<p th:text="${message}"></p>
</body>
</html>

这时访问 http://localhost:8080/spring/ex 即可看到如下错误,说明确实映射到了500.html

注:如果同时存在静态页面500.html和动态模板的500.html,则后者覆盖前者。即templates/error/这个的优先级比resources/public/error高。


整体概括上面几种情况,如下:

  • error.html会覆盖默认的 whitelabel Error Page 错误提示

  • 静态错误页面优先级别比error.html高

  • 动态模板错误页面优先级比静态错误页面高

3、上面介绍的只是最简单的覆盖错误页面的方式来自定义,如果对于某些错误你可能想特殊对待,则可以这样

@Configuration 
publicclassContainerConfig{
@Bean
public EmbeddedServletContainerCustomizer containerCustomizer{
return new EmbeddedServletContainerCustomizer{
@Override
publicvoidcustomize(ConfigurableEmbeddedServletContainer container) {
container.addErrorPages(new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error/500"));
}
};
}
}

上面这段代码中HttpStatus.INTERNAL_SERVER_ERROR就是对应500错误码,也就是说程序如果发生500错误,就会将请求转发到/error/500这个映射来,那我们只要实现一个方法是对应这个/error/500映射即可捕获这个异常做出处理

@RequestMapping("/error/500")
@ResponseBody
public String showServerError {
return "server error";
}

这样,我们再请求前面提到的异常请求 http://localhost:8080/spring/ex 的时候,就会被我们这个方法捕获了。

这里我们就只对500做了特殊处理,并且返还的是字符串,如果想要返回视图,去掉 @ResponseBody注解,并返回对应的视图页面。如果想要对其他状态码自定义映射,在customize方法中添加即可。

上面这种方法虽然我们重写了/500映射,但是有一个问题就是无法获取错误信息,想获取错误信息的话,我们可以继承BasicErrorController或者干脆自己实现ErrorController接口,除了用来响应/error这个错误页面请求,可以提供更多类型的错误格式等(BasicErrorController在上面介绍SpringBoot默认异常机制的时候有提到)

这里博主选择直接继承BasicErrorController,然后把上面 /error/500映射方法添加进来即可

@Controller
public class MyBasicErrorController extends BasicErrorController {

public MyBasicErrorController {
super(new DefaultErrorAttributes, new ErrorProperties);
}

/**
* 定义500的ModelAndView
* @param request
* @param response
* @return
*/

@RequestMapping(produces="text/html",value="/500")
public ModelAndView errorHtml500(HttpServletRequest request,HttpServletResponse response) {
response.setStatus(getStatus(request).value);
Map<String, Object> model=getErrorAttributes(request,isIncludeStackTrace(request, MediaType.TEXT_HTML));
model.put("msg","自定义错误信息");
return new ModelAndView("error/500", model);
}

/**
* 定义500的错误JSON信息
* @param request
* @return
*/

@RequestMapping(value="/500")
@ResponseBody

public ResponseEntity<Map<String, Object>> error500(HttpServletRequest request) {
Map<String, Object> body=getErrorAttributes(request,isIncludeStackTrace(request, MediaType.TEXT_HTML));
HttpStatus status=getStatus(request);
return new ResponseEntity<Map<String, Object>>(body, status);
}
}

代码也很简单,只是实现了自定义的500错误的映射解析,分别对浏览器请求以及json请求做了回应。

BasicErrorController默认对应的@RequestMapping是/error,固我们方法里面对应的@RequestMapping(produces="text/html",value="/500")实际上完整的映射请求是/error/500,这就跟上面 customize 方法自定义的映射路径对上了。

errorHtml500 方法中,我返回的是模板页面,对应/templates/error/500.html,这里顺便自定义了一个msg信息,在500.html也输出这个信息<p th:text="${msg}"></p>,如果输出结果有这个信息,则表示我们配置正确了。

再次访问请求http://localhost:8080/spring/ex ,结果如下

Tips:大家可以关注微信公众号:Java后端,获取更多推送。

通过@ControllerAdvice注解来处理异常

Spring Boot提供的ErrorController是一种全局性的容错机制。此外,你还可以用@ControllerAdvice注解和@ExceptionHandler注解实现对指定异常的特殊处理。

这里介绍两种情况:

  • 局部异常处理 @Controller + @ExceptionHandler

  • 全局异常处理 @ControllerAdvice + @ExceptionHandler

局部异常处理 @Controller + @ExceptionHandler

局部异常主要用到的是@ExceptionHandler注解,此注解注解到类的方法上,当此注解里定义的异常抛出时,此方法会被执行。如果@ExceptionHandler所在的类是@Controller,则此方法只作用在此类。如果@ExceptionHandler所在的类带有@ControllerAdvice注解,则此方法会作用在全局。

该注解用于标注处理方法处理那些特定的异常。被该注解标注的方法可以有以下任意顺序的参数类型:

Throwable、Exception 等异常对象;

ServletRequest、HttpServletRequest、ServletResponse、HttpServletResponse;

HttpSession 等会话对象;

org.springframework.web.context.request.WebRequest;

java.util.Locale;

java.io.InputStream、java.io.Reader;

java.io.OutputStream、java.io.Writer;

org.springframework.ui.Model;

并且被该注解标注的方法可以有以下的返回值类型可选:

ModelAndView;

org.springframework.ui.Model;

java.util.Map;

org.springframework.web.servlet.View;

@ResponseBody 注解标注的任意对象;

HttpEntity<?> or ResponseEntity<?>;

void;

以上罗列的不完全,更加详细的信息可参考:Spring ExceptionHandler。

举个简单例子,这里我们对除0异常用@ExceptionHandler来捕捉。

@Controller
publicclassBaseErrorControllerextendsAbstractController{
private Logger logger=LoggerFactory.getLogger(this.getClass);

@RequestMapping(value="/ex")
@ResponseBody
public String error{
int i=5/0;
return "ex";
}

//局部异常处理
@ExceptionHandler(Exception.class)
@ResponseBody
public String exHandler(Exception e){
// 判断发生异常的类型是除0异常则做出响应
if(e instanceof ArithmeticException){
return "发生了除0异常";
}
// 未知的异常做出响应
return "发生了未知异常";
}
}

全局异常处理 @ControllerAdvice + @ExceptionHandler

在spring 3.2中,新增了@ControllerAdvice 注解,可以用于定义@ExceptionHandler、@InitBinder、@ModelAttribute,并应用到所有@RequestMapping中。

简单的说,进入Controller层的错误才会由@ControllerAdvice处理,拦截器抛出的错误以及访问错误地址的情况@ControllerAdvice处理不了,由SpringBoot默认的异常处理机制处理。

我们实际开发中,如果是要实现RESTful API,那么默认的JSON错误信息就不是我们想要的,这时候就需要统一一下JSON格式,所以需要封装一下。

/**
* 返回数据
*/
public class AjaxObject extends HashMap<String, Object> {
private static final long serialVersionUID=1L;

publicAjaxObject {
put("code", 0);
}

public static AjaxObject error {
return error(HttpStatus.SC_INTERNAL_SERVER_ERROR, "未知异常,请联系管理员");
}

public static AjaxObject error(String msg) {
return error(HttpStatus.SC_INTERNAL_SERVER_ERROR, msg);
}

public static AjaxObject error(int code, String msg) {
AjaxObject r=new AjaxObject;
r.put("code", code);
r.put("msg", msg);
return r;
}

public static AjaxObject ok(String msg) {
AjaxObject r=new AjaxObject;
r.put("msg", msg);
return r;
}

public static AjaxObject ok(Map<String, Object> map) {
AjaxObject r=new AjaxObject;
r.putAll(map);
return r;
}

public static AjaxObject ok {
return new AjaxObject;
}

public AjaxObject put(String key, Object value) {
super.put(key, value);
return this;
}

public AjaxObject data(Object value) {
super.put("data", value);
return this;
}

public static AjaxObject apiError(String msg) {
return error(1, msg);
}
}

上面这个AjaxObject就是我平时用的,如果是正确情况返回的就是:

{
code:0,
msg:“获取列表成功”,
data:{
queryList :
}
}

正确默认code返回0,data里面可以是集合,也可以是对象,如果是异常情况,返回的json则是:

{
code:500,
msg:“未知异常,请联系管理员”
}

然后创建一个自定义的异常类:

public classBusinessExceptionextendsRuntimeExceptionimplementsSerializable{

private static final long serialVersionUID=1L;
private String msg;
private int code=500;

publicBusinessException(String msg) {
super(msg);
this.msg=msg;
}

publicBusinessException(String msg, Throwable e) {
super(msg, e);
this.msg=msg;
}

publicBusinessException(int code,String msg) {
super(msg);
this.msg=msg;
this.code=code;
}

publicBusinessException(String msg, int code, Throwable e) {
super(msg, e);
this.msg=msg;
this.code=code;
}

public String getMsg {
return msg;
}

publicvoidsetMsg(String msg) {
this.msg=msg;
}

publicintgetCode {
return code;
}

publicvoidsetCode(int code) {
this.code=code;
}
}

注:spring 对于 RuntimeException 异常才会进行事务回滚

Controler中添加一个json映射,用来处理这个异常

@Controller
public class BaseErrorController{
@RequestMapping("/json")
public void json(ModelMap modelMap) {
System.out.println(modelMap.get("author"));
int i=5/0;
}
}

最后创建这个全局异常处理类:

/**
* 异常处理器
*/
@RestControllerAdvice
publicclassBusinessExceptionHandler{
private Logger logger=LoggerFactory.getLogger(getClass);



/**
* 应用到所有@RequestMapping注解方法,在其执行之前初始化数据绑定器
* @param binder
*/
@InitBinder
publicvoidinitBinder(WebDataBinder binder) {
System.out.println("请求有参数才进来");
}

/**
* 把值绑定到Model中,使全局@RequestMapping可以获取到该值
* @param model
*/
@ModelAttribute
publicvoidaddAttributes(Model model) {
model.addAttribute("author", "嘟嘟MD");
}

@ExceptionHandler(Exception.class)
public Object handleException(Exception e,HttpServletRequest req){
AjaxObject r=new AjaxObject;
//业务异常
if(e instanceof BusinessException){
r.put("code", ((BusinessException) e).getCode);
r.put("msg", ((BusinessException) e).getMsg);
}else{//系统异常
r.put("code","500");
r.put("msg","未知异常,请联系管理员");
}

//使用HttpServletRequest中的header检测请求是否为ajax, 如果是ajax则返回json, 如果为非ajax则返回view(即ModelAndView)
String contentTypeHeader=req.getHeader("Content-Type");
String acceptHeader=req.getHeader("Accept");
String xRequestedWith=req.getHeader("X-Requested-With");
if ((contentTypeHeader !=&& contentTypeHeader.contains("application/json"))
|| (acceptHeader !=&& acceptHeader.contains("application/json"))
|| "XMLHttpRequest".equalsIgnoreCase(xRequestedWith)) {
return r;
} else {
ModelAndView modelAndView=new ModelAndView;
modelAndView.addObject("msg", e.getMessage);
modelAndView.addObject("url", req.getRequestURL);
modelAndView.addObject("stackTrace", e.getStackTrace);
modelAndView.setViewName("error");
return modelAndView;
}
}
}

@ExceptionHandler 拦截了异常,我们可以通过该注解实现自定义异常处理。其中,@ExceptionHandler 配置的 value 指定需要拦截的异常类型,上面我配置了拦截Exception,

再根据不同异常类型返回不同的相应,最后添加判断,如果是Ajax请求,则返回json,如果是非ajax则返回view,这里是返回到error.html页面。

为了展示错误的时候更友好,我封装了下error.html,不仅展示了错误,还添加了跳转百度谷歌以及StackOverFlow的按钮,如下:

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org" layout:decorator="layout">
<head>
<title>Spring Boot管理后台</title>
<script type="text/javascript">
</script>
</head>
<body>
<div layout:fragment="content" th:remove="tag">
<div id="navbar">
<h1>系统异常统一处理</h1>
<h3 th:text="'错误信息:'+${msg}"></h3>
<h3 th:text="'请求地址:'+${url}"></h3>

<h2>Debug</h2>
<a th:href="@{'https://www.google.com/webhp?hl=zh-CN#safe=strict&hl=zh-CN&q='+${msg}}"
class="btn btn-primary btn-lg" target="_blank" id="Google">Google</a>
<a th:href="@{'https://www.baidu.com/s?wd='+${msg}}" class="btn btn-info btn-lg" target="_blank" id="Baidu">Baidu</a>
<a th:href="@{'http://stackoverflow.com/search?q='+${msg}}"
class="btn btn-default btn-lg" target="_blank" id="StackOverFlow">StackOverFlow</a>
<h2>异常堆栈跟踪日志StackTrace</h2>
<div th:each="line:${stackTrace}">
<div th:text="${line}"></div>
</div>
</div>
</div>
<div layout:fragment="js" th:remove="tag">
</div>
</body>
</html>

访问http://localhost:8080/json的时候,因为是浏览器发起的,返回的是error界面:

如果是ajax请求,返回的就是错误:

{ "msg":"未知异常,请联系管理员", "code":500 }

这里我给带@ModelAttribute注解的方法通过Model设置了author值,在json映射方法中通过 ModelMwap 获取到改值。

认真的你可能发现,全局异常类我用的是@RestControllerAdvice,而不是@ControllerAdvice,因为这里返回的主要是json格式,这样可以少写一个@ResponseBody。

总结

到此,SpringBoot中对异常的使用也差不多全了,本项目中处理异常的顺序会是这样,当发送一个请求:

1.拦截器那边先判断是否登录,没有则返回登录页。

2.在进入Controller之前,譬如请求一个不存在的地址,返回404错误界面。

3.在执行@RequestMapping时,发现的各种错误(譬如数据库报错、请求参数格式错误/缺失/值非法等)统一由@ControllerAdvice处理,根据是否Ajax返回json或者view。

-END-