做者:西格玛
lrwinx.github.io/2016/04/28/如何优雅的设计java异常/
2019-08-20 09:31:00
导语java
异常处理是程序开发中必不可少操做之一,但如何正确优雅的对异常进行处理确是一门学问,笔者根据本身的开发经验来谈一谈我是如何对异常进行处理的。mysql
因为本文只做一些经验之谈,不涉及到基础知识部分,若是读者对异常的概念还很模糊,请先查看基础知识。git
如何选择异常类型github
异常的类别web
正如咱们所知道的,java中的异常的超类是java.lang.Throwable(后文省略为Throwable),它有两个比较重要的子类,java.lang.Exception(后文省略为Exception)和java.lang.Error(后文省略为Error),其中Error由JVM虚拟机进行管理,如咱们所熟知的OutOfMemoryError异常等,因此咱们本文不关注Error异常,那么咱们细说一下Exception异常。面试
Exception异常有个比较重要的子类,叫作RuntimeException。咱们将RuntimeException或其余继承自RuntimeException的子类称为非受检异常(unchecked Exception),其余继承自Exception异常的子类称为受检异常(checked Exception)。本文重点来关注一下受检异常和非受检异常这两种异常。redis
如何选择异常spring
从笔者的开发经验来看,若是在一个应用中,须要开发一个方法(如某个功能的service方法),这个方法若是中间可能出现异常,那么你须要考虑这个异常出现以后是否调用者能够处理,而且你是否但愿调用者进行处理,若是调用者能够处理,而且你也但愿调用者进行处理,那么就要抛出受检异常,提醒调用者在使用你的方法时,考虑到若是抛出异常时若是进行处理,类似的,若是在写某个方法时,你认为这是个偶然异常,理论上说,你以为运行时可能会碰到什么问题,而这些问题也许不是必然发生的,也不须要调用者显示的经过异常来判断业务流程操做的,那么这时就可使用一个RuntimeException这样的非受检异常.sql
好了,估计我上边说的这段话,你读了不少遍也依然以为晦涩了。数据库
那么,请跟着个人思路,在慢慢领会一下。
何时才须要抛异常
首先咱们须要了解一个问题,何时才须要抛异常?异常的设计是方便给开发者使用的,但不是乱用的,笔者对于何时抛异常这个问题也问了不少朋友,能给出准确答案的确实很少。其实这个问题很简单,若是你以为某些”问题”解决不了了,那么你就能够抛出异常了。好比,你在写一个service,其中在写到某段代码处,你发现可能会产生问题,那么就请抛出异常吧,相信我,你此时抛出异常将是一个最佳时机。
应该抛出怎样的异常
了解完了何时才须要抛出异常后,咱们再思考一个问题,真的当咱们抛出异常时,咱们应该选用怎样的异常呢?到底是受检异常仍是非受检异常呢(RuntimeException)呢?我来举例说明一下这个问题,先从受检异常提及,好比说有这样一个业务逻辑,须要从某文件中读取某个数据,这个读取操做多是因为文件被删除等其余问题致使没法获取从而出现读取错误,那么就要从redis或mysql数据库中再去获取此数据,参考以下代码,getKey(Integer)为入口程序.
public String getKey(Integer key){String value;
try {
InputStream inputStream = getFiles("/file/nofile");
//接下来从流中读取key的value指
value = ...;
} catch (Exception e) {
//若是抛出异常将从mysql或者redis进行取之
value = ...;
}
}
public InputStream getFiles(String path) throws Exception {
File file = new File(path);
InputStream inputStream = null;
try {
inputStream = new BufferedInputStream(new FileInputStream(file));
} catch (FileNotFoundException e) {
throw new Exception("I/O读取错误",e.getCause());
}
return inputStream;
}
ok,看了以上代码之后,你也许心中有一些想法,原来受检异常能够控制义务逻辑,对,没错,经过受检异常真的能够控制业务逻辑,可是切记不要这样使用,咱们应该合理的抛出异常,由于程序自己才是流程,异常的做用仅仅是当你进行不下去的时候找到的一个借口而已,它并不能当成控制程序流程的入口或出口,若是这样使用的话,是在将异常的做用扩大化,这样将会致使代码复杂程度的增长,耦合性会提升,代码可读性下降等问题。那么就必定不要使用这样的异常吗?其实也不是,在真的有这样的需求的时候,咱们能够这样使用,只是切记,不要把它真的当成控制流程的工具或手段。那么究竟何时才要抛出这样的异常呢?要考虑,若是调用者调用出错后,必定要让调用者对此错误进行处理才能够,知足这样的要求时,咱们才会考虑使用受检异常。
接下来,咱们来看一下非受检异常呢(RuntimeException),对于RuntimeException这种异常,咱们其实不少见,好比java.lang.NullPointerException/java.lang.IllegalArgumentException等,那么这种异常咱们时候抛出呢?当咱们在写某个方法的时候,可能会偶然遇到某个错误,咱们认为这个问题时运行时可能为发生的,而且理论上讲,没有这个问题的话,程序将会正常执行的时候,它不强制要求调用者必定要捕获这个异常,此时抛出RuntimeException异常,举个例子,当传来一个路径的时候,须要返回一个路径对应的File对象:
public void test() {myTest.getFiles("");
}
public File getFiles(String path) {
if(null == path || "".equals(path)){
throw new NullPointerException("路径不能为空!");
}
File file = new File(path);
return file;
}
上述例子代表,若是调用者调用getFiles(String)的时候若是path是空,那么就抛出空指针异常(它是RuntimeException的子类),调用者不用显示的进行try…catch…操做进行强制处理.这就要求调用者在调用这样的方法时先进行验证,避免发生RuntimeException.以下:
应该选用哪一种异常
经过以上的描述和举例,能够总结出一个结论,RuntimeException异常和受检异常之间的区别就是:是否强制要求调用者必须处理此异常,若是强制要求调用者必须进行处理,那么就使用受检异常,不然就选择非受检异常(RuntimeException)。通常来说,若是没有特殊的要求,咱们建议使用RuntimeException异常。
场景介绍和技术选型
架构描述
正如咱们所知,传统的项目都是以MVC框架为基础进行开发的,本文主要从使用restful风格接口的设计来体验一下异常处理的优雅。
咱们把关注点放在restful的api层(和web中的controller层相似)和service层,研究一下在service中如何抛出异常,而后api层如何进行捕获而且转化异常。
使用的技术是:spring-boot,jpa(hibernate),mysql,若是对这些技术不是太熟悉,读者须要自行阅读相关材料。
业务场景描述
选择一个比较简单的业务场景,以电商中的收货地址管理为例,用户在移动端进行购买商品时,须要进行收货地址管理,在项目中,提供一些给移动端进行访问的api接口,如:添加收货地址,删除收货地址,更改收货地址,默认收货地址设置,收货地址列表查询,单个收货地址查询等接口。
构建约束条件
ok,这个是设置好的一个很基本的业务场景,固然,不管什么样的api操做,其中都包含一些规则:
添加收货地址:
入参:
约束:
删除收货地址:
入参:
约束:
更改收货地址:
入参:
约束:
默认地址设置:
入参:
约束:
收货地址列表查询:
入参:
约束:
单个收货地址查询:
入参:
约束:
约束判断和技术选型
对于上述列出的约束条件和功能列表,我选择几个比较典型的异常处理场景进行分析:添加收货地址,删除收货地址,获取收货地址列表。
那么应该有哪些必要的知识储备呢,让咱们看一下收货地址这个功能:
添加收货地址中须要对用户id和收货地址实体信息就行校验,那么对于非空的判断,咱们如何进行工具的选择呢?传统的判断以下:
/*** 添加地址
* @param uid
* @param address
* @return
*/
public Address addAddress(Integer uid,Address address){
if(null != uid){
//进行处理..
}
return null;
}
上边的例子,若是只判断uid为空还好,若是再去判断address这个实体中的某些必要属性是否为空,在字段不少的状况下,这无非是灾难性的。
那咱们应该怎么进行这些入参的判断呢,给你们介绍两个知识点:
若是使用了这两种推荐技术,那么入参的判断会变得简单不少。推荐你们多使用这些成熟的技术和jar工具包,他能够减小不少没必要要的工做量。咱们只须要把重心放到业务逻辑上。而不会由于这些入参的判断耽误更多的时间。
如何优雅的设计java异常
domain介绍
根据项目场景来看,须要两个domain模型,一个是用户实体,一个是地址实体.
Address domain以下:
@Entity@Data
public class Address {
@Id
@GeneratedValue
private Integer id;
private String province;//省
private String city;//市
private String county;//区
private Boolean isDefault;//是不是默认地址
@ManyToOne(cascade={CascadeType.ALL})
@JoinColumn(name="uid")
private User user;
}
User domain以下:
@Entity@Data
public class User {
@Id
@GeneratedValue
private Integer id;
private String name;//姓名
@OneToMany(cascade= CascadeType.ALL,mappedBy="user",fetch = FetchType.LAZY)
private Set<Address> addresses;
}
ok,上边是一个模型关系,用户-收货地址的关系是1-n的关系。上边的@Data是使用了一个叫作lombok的工具,它自动生成了Setter和Getter等方法,用起来很是方便,感兴趣的读者能够自行了解一下。
dao介绍
数据链接层,咱们使用了spring-data-jpa这个框架,它要求咱们只须要继承框架提供的接口,而且按照约定对方法进行取名,就能够完成咱们想要的数据库操做。
用户数据库操做以下:
@Repositorypublic interface IUserDao extends JpaRepository<User,Integer> {
}
收货地址操做以下:
@Repositorypublic interface IAddressDao extends JpaRepository<Address,Integer> {
}
正如读者所看到的,咱们的DAO只须要继承JpaRepository,它就已经帮咱们完成了基本的CURD等操做,若是想了解更多关于spring-data的这个项目,请参考一下spring的官方文档,它比不方案咱们对异常的研究。
Service异常设计
ok,终于到了咱们的重点了,咱们要完成service一些的部分操做:添加收货地址,删除收货地址,获取收货地址列表.
首先看个人service接口定义:
public interface IAddressService {/**
* 建立收货地址
* @param uid
* @param address
* @return
*/
Address createAddress(Integer uid,Address address);
/**
* 删除收货地址
* @param uid
* @param aid
*/
void deleteAddress(Integer uid,Integer aid);
/**
* 查询用户的全部收货地址
* @param uid
* @return
*/
List<Address> listAddresses(Integer uid);
}
咱们来关注一下实现:
添加收货地址
首先再来看一下以前整理的约束条件:
入参:
约束:
先看如下代码实现:
@Overridepublic Address createAddress(Integer uid, Address address) {
//============ 如下为约束条件 ==============
//1.用户id不能为空,且此用户确实是存在的
Preconditions.checkNotNull(uid);
User user = userDao.findOne(uid);
if(null == user){
throw new RuntimeException("找不到当前用户!");
}
//2.收货地址的必要字段不能为空
BeanValidators.validateWithException(validator, address);
//3.若是用户尚未收货地址,当此收货地址建立时设置成默认收货地址
if(ObjectUtils.isEmpty(user.getAddresses())){
address.setIsDefault(true);
}
//============ 如下为正常执行的业务逻辑 ==============
address.setUser(user);
Address result = addressDao.save(address);
return result;
}
其中,已经完成了上述所描述的三点约束条件,当三点约束条件都知足时,才能够进行正常的业务逻辑,不然将抛出异常(通常在此处建议抛出运行时异常-RuntimeException)。
介绍如下以上我所用到的技术:
1. Preconfitions.checkNotNull(T t)这个是使用Guava中的com.google.common.base.Preconditions进行判断的,由于service中用到的验证较多,因此建议将Preconfitions改为静态导入的方式:
import static com.google.common.base.Preconditions.checkNotNull;
固然Guava的github中的说明也建议咱们这样使用。
2. BeanValidators.validateWithException(validator, address);
这个使用了hibernate实现的jsr 303规范来作的,须要传入一个validator和一个须要验证的实体,那么validator是如何获取的呢,以下:
@Configurationpublic class BeanConfigs {
@Bean
public javax.validation.Validator getValidator(){
return new LocalValidatorFactoryBean();
}
}
他将获取一个Validator对象,而后咱们在service中进行注入即可以使用了:
@Autowired
private Validator validator ;
那么BeanValidators这个类是如何实现的?其实实现方式很简单,只要去判断jsr 303的标注注解就ok了。
那么jsr 303的注解写在哪里了呢?固然是写在address实体类中了:
@Entity@Setter
@Getter
public class Address {
@Id
@GeneratedValue
private Integer id;
@NotNull
private String province;//省
@NotNull
private String city;//市
@NotNull
private String county;//区
private Boolean isDefault = false;//是不是默认地址
@ManyToOne(cascade={CascadeType.ALL})
@JoinColumn(name="uid")
private User user;
}
写好你须要的约束条件来进行判断,若是合理的话,才能够进行业务操做,从而对数据库进行操做。
这块的验证是必须的,一个最主要的缘由是:这样的验证能够避免脏数据的插入。若是读者有正式上线的经验的话,就能够理解这样的一个事情,任何的代码错误均可以容忍和修改,可是若是出现了脏数据问题,那么它有多是一个毁灭性的灾难。程序的问题能够修改,可是脏数据的出现有可能没法恢复。因此这就是为何在service中必定要判断好约束条件,再进行业务逻辑操做的缘由了。
此处的判断为业务逻辑判断,是从业务角度来进行筛选判断的,除此以外,有可能在不少场景中都会有不一样的业务条件约束,只须要按照要求来作就好。
对于约束条件的总结以下:
当这个三点都知足时,才能够进行下一步操做
ok,基本介绍了如何作一个基础的判断,那么再回到异常的设计问题上,上述代码已经很清楚的描述如何在适当的位置合理的判断一个异常了,那么如何合理的抛出异常呢?
只抛出RuntimeException就算是优雅的抛出异常吗?固然不是,对于service中的抛出异常,笔者认为大体有两种抛出的方法:
相对这两种异常的方式进行结束,第一种异常指的是我全部的异常都抛RuntimeException异常,可是须要带一个状态码,调用者能够根据状态码再去查询究竟service抛出了一个什么样的异常。
第二种异常是指在service中抛出什么样的异常就自定义一个指定的异常错误,而后在进行抛出异常。
通常来说,若是系统没有别的特殊需求的时候,在开发设计中,建议使用第二种方式。可是好比说像基础判断的异常,就能够彻底使用guava给咱们提供的类库进行操做。jsr 303异常也可使用本身封装好的异常判断类进行操做,由于这两种异常都是属于基础判断,不须要为它们指定特殊的异常。可是对于第三点义务条件约束判断抛出的异常,就须要抛出指定类型的异常了。
对于
throw new RuntimeException("找不到当前用户!");
定义一个特定的异常类来进行这个义务异常的判断:
public class NotFindUserException extends RuntimeException {public NotFindUserException() {
super("找不到此用户");
}
public NotFindUserException(String message) {
super(message);
}
}
而后将此处改成:
throw new NotFindUserException("找不到当前用户!");
or
throw new NotFindUserException();
ok,经过以上对service层的修改,代码更改以下:
@Overridepublic Address createAddress(Integer uid, Address address) {
//============ 如下为约束条件 ==============
//1.用户id不能为空,且此用户确实是存在的
checkNotNull(uid);
User user = userDao.findOne(uid);
if(null == user){
throw new NotFindUserException("找不到当前用户!");
}
//2.收货地址的必要字段不能为空
BeanValidators.validateWithException(validator, address);
//3.若是用户尚未收货地址,当此收货地址建立时设置成默认收货地址
if(ObjectUtils.isEmpty(user.getAddresses())){
address.setIsDefault(true);
}
//============ 如下为正常执行的业务逻辑 ==============
address.setUser(user);
Address result = addressDao.save(address);
return result;
}
这样的service就看起来稳定性和理解性就比较强了。
删除收货地址:
入参:
约束:
它与上述添加收货地址相似,故再也不赘述,delete的service设计以下:
@Overridepublic void deleteAddress(Integer uid, Integer aid) {
//============ 如下为约束条件 ==============
//1.用户id不能为空,且此用户确实是存在的
checkNotNull(uid);
User user = userDao.findOne(uid);
if(null == user){
throw new NotFindUserException();
}
//2.收货地址不能为空,且此收货地址确实是存在的
checkNotNull(aid);
Address address = addressDao.findOne(aid);
if(null == address){
throw new NotFindAddressException();
}
//3.判断此收货地址是不是用户的收货地址
if(!address.getUser().equals(user)){
throw new NotMatchUserAddressException();
}
//4.判断此收货地址是否为默认收货地址,若是是默认收货地址,那么不能进行删除
if(address.getIsDefault()){
throw new DefaultAddressNotDeleteException();
}
//============ 如下为正常执行的业务逻辑 ==============
addressDao.delete(address);
}
设计了相关的四个异常类:NotFindUserException,NotFindAddressException,NotMatchUserAddressException,DefaultAddressNotDeleteException.根据不一样的业务需求抛出不一样的异常。
获取收货地址列表:
入参:
约束:
代码以下:
@Overridepublic List<Address> listAddresses(Integer uid) {
//============ 如下为约束条件 ==============
//1.用户id不能为空,且此用户确实是存在的
checkNotNull(uid);
User user = userDao.findOne(uid);
if(null == user){
throw new NotFindUserException();
}
//============ 如下为正常执行的业务逻辑 ==============
User result = userDao.findOne(uid);
return result.getAddresses();
}
api异常设计
大体有两种抛出的方法:
这个是在设计service层异常时提到的,经过对service层的介绍,咱们在service层抛出异常时选择了第二种抛出的方式,不一样的是,在api层抛出异常咱们须要使用这两种方式进行抛出:要指定api异常的类型,而且要指定相关的状态码,而后才将异常抛出,这种异常设计的核心是让调用api的使用者更能清楚的了解发生异常的详细信息,除了抛出异常外,咱们还须要将状态码对应的异常详细信息以及异常有可能发生的问题制做成一个对应的表展现给用户,方便用户的查询。(如github提供的api文档,微信提供的api文档等),还有一个好处:若是用户须要自定义提示消息,能够根据返回的状态码进行提示的修改。
api验证约束
首先对于api的设计来讲,须要存在一个dto对象,这个对象负责和调用者进行数据的沟通和传递,而后dto->domain在传给service进行操做,这一点必定要注意,第二点,除了说道的service须要进行基础判断(null判断)和jsr 303验证之外,一样的,api层也须要进行相关的验证,若是验证不经过的话,直接返回给调用者,告知调用失败,不该该带着不合法的数据再进行对service的访问,那么读者可能会有些迷惑,不是service已经进行验证了,为何api层还须要进行验证么?这里便设计到了一个概念:编程中的墨菲定律,若是api层的数据验证疏忽了,那么有可能不合法数据就带到了service层,进而讲脏数据保存到了数据库。
因此缜密编程的核心是:永远不要相信收到的数据是合法的。
api异常设计
设计api层异常时,正如咱们上边所说的,须要提供错误码和错误信息,那么能够这样设计,提供一个通用的api超类异常,其余不一样的api异常都继承自这个超类:
public class ApiException extends RuntimeException {protected Long errorCode ;
protected Object data ;
public ApiException(Long errorCode,String message,Object data,Throwable e){
super(message,e);
this.errorCode = errorCode ;
this.data = data ;
}
public ApiException(Long errorCode,String message,Object data){
this(errorCode,message,data,null);
}
public ApiException(Long errorCode,String message){
this(errorCode,message,null,null);
}
public ApiException(String message,Throwable e){
this(null,message,null,e);
}
public ApiException(){
}
public ApiException(Throwable e){
super(e);
}
public Long getErrorCode() {
return errorCode;
}
public void setErrorCode(Long errorCode) {
this.errorCode = errorCode;
}
public Object getData() {
return data;
}
public void setData(Object data) {
this.data = data;
}
}
而后分别定义api层异常:ApiDefaultAddressNotDeleteException,ApiNotFindAddressException,ApiNotFindUserException,ApiNotMatchUserAddressException。
以默认地址不能删除为例:
public class ApiDefaultAddressNotDeleteException extends ApiException {public ApiDefaultAddressNotDeleteException(String message) {
super(AddressErrorCode.DefaultAddressNotDeleteErrorCode, message, null);
}
}
AddressErrorCode.DefaultAddressNotDeleteErrorCode就是须要提供给调用者的错误码。错误码类以下:
public abstract class AddressErrorCode {public static final Long DefaultAddressNotDeleteErrorCode = 10001L;//默认地址不能删除
public static final Long NotFindAddressErrorCode = 10002L;//找不到此收货地址
public static final Long NotFindUserErrorCode = 10003L;//找不到此用户
public static final Long NotMatchUserAddressErrorCode = 10004L;//用户与收货地址不匹配
}
ok,那么api层的异常就已经设计完了,在此多说一句,AddressErrorCode错误码类存放了可能出现的错误码,更合理的作法是把他放到配置文件中进行管理。
api处理异常
api层会调用service层,而后来处理service中出现的全部异常,首先,须要保证一点,必定要让api层很是轻,基本上作成一个转发的功能就好(接口参数,传递给service参数,返回给调用者数据,这三个基本功能),而后就要在传递给service参数的那个方法调用上进行异常处理。
此处仅以添加地址为例:
@Autowiredprivate IAddressService addressService;
/**
* 添加收货地址
* @param addressDTO
* @return
*/
@RequestMapping(method = RequestMethod.POST)
public AddressDTO add(@Valid @RequestBody AddressDTO addressDTO){
Address address = new Address();
BeanUtils.copyProperties(addressDTO,address);
Address result;
try {
result = addressService.createAddress(addressDTO.getUid(), address);
}catch (NotFindUserException e){
throw new ApiNotFindUserException("找不到该用户");
}catch (Exception e){//未知错误
throw new ApiException(e);
}
AddressDTO resultDTO = new AddressDTO();
BeanUtils.copyProperties(result,resultDTO);
resultDTO.setUid(result.getUser().getId());
return resultDTO;
}
这里的处理方案是调用service时,判断异常的类型,而后将任何service异常都转化成api异常,而后抛出api异常,这是经常使用的一种异常转化方式。类似删除收货地址和获取收货地址也相似这样处理,在此,不在赘述。
api异常转化
已经讲解了如何抛出异常和何如将service异常转化为api异常,那么转化成api异常直接抛出是否就完成了异常处理呢? 答案是否认的,当抛出api异常后,咱们须要把api异常返回的数据(json or xml)让用户看懂,那么须要把api异常转化成dto对象(ErrorDTO),看以下代码:
@ControllerAdvice(annotations = RestController.class)class ApiExceptionHandlerAdvice {
/**
* Handle exceptions thrown by handlers.
*/
@ExceptionHandler(value = Exception.class)
@ResponseBody
public ResponseEntity<ErrorDTO> exception(Exception exception,HttpServletResponse response) {
ErrorDTO errorDTO = new ErrorDTO();
if(exception instanceof ApiException){//api异常
ApiException apiException = (ApiException)exception;
errorDTO.setErrorCode(apiException.getErrorCode());
}else{//未知异常
errorDTO.setErrorCode(0L);
}
errorDTO.setTip(exception.getMessage());
ResponseEntity<ErrorDTO> responseEntity = new ResponseEntity<>(errorDTO,HttpStatus.valueOf(response.getStatus()));
return responseEntity;
}
@Setter
@Getter
class ErrorDTO{
private Long errorCode;
private String tip;
}
}
ok,这样就完成了api异常转化成用户能够读懂的DTO对象了,代码中用到了@ControllerAdvice,这是spring MVC提供的一个特殊的切面处理。
当调用api接口发生异常时,用户也能够收到正常的数据格式了,好比当没有用户(uid为2)时,却为这个用户添加收货地址,postman(Google plugin 用于模拟http请求)以后的数据:
{"errorCode": 10003,
"tip": "找不到该用户"
}
总结
本文只从如何设计异常做为重点来说解,涉及到的api传输和service的处理,还有待优化,好比api接口访问须要使用https进行加密,api接口须要OAuth2.0受权或api接口须要签名认证等问题,文中都不曾提到,本文的重心在于异常如何处理,因此读者只需关注涉及到异常相关的问题和处理方式就能够了。但愿本篇文章对你理解异常有所帮助。
关注Java技术栈微信公众号,在后台回复关键字:Java,能够获取一份栈长整理的 Java 最新技术干货。
推荐去个人博客阅读更多:
2.Spring MVC、Spring Boot、Spring Cloud 系列教程
3.Maven、Git、Eclipse、Intellij IDEA 系列工具教程
以为不错,别忘了点赞+转发哦!