个人公众号: MarkerHub,网站: https://markerhub.com更多精选文章请点击:Java笔记大全.mdjava
做者以增删改查收货地址为实例,详细说明了如何去设计一个好的异常处理,包括使用Guava中的Preconditions、hibernate的hibernate-validator,还有如何异常和处理异常的逻辑,文章有点长,看完仍是收获挺大!mysql
异常处理是程序开发中必不可少操做之一,但如何正确优雅的对异常进行处理确是一门学问,笔者根据本身的开发经验来谈一谈我是如何对异常进行处理的。git
因为本文只做一些经验之谈,不涉及到基础知识部分,若是读者对异常的概念还很模糊,请先查看基础知识。github
正如咱们所知道的,java 中的异常的超类是 java.lang.Throwable(后文省略为 Throwable), 它有两个比较重要的子类, java.lang.Exception(后文省略为 Exception) 和 java.lang.Error(后文省略为 Error),其中 Error 由 JVM 虚拟机进行管理, 如咱们所熟知的 OutOfMemoryError 异常等,因此咱们本文不关注 Error 异常,那么咱们细说一下 Exception 异常。web
Exception 异常有个比较重要的子类,叫作 RuntimeException。咱们将 RuntimeException 或其余继承自 RuntimeException 的子类称为非受检异常 (unchecked Exception),其余继承自 Exception 异常的子类称为受检异常 (checked Exception)。本文重点来关注一下受检异常和非受检异常这两种异常。redis
从笔者的开发经验来看,若是在一个应用中,须要开发一个方法 (如某个功能的 service 方法),这个方法若是中间可能出现异常,那么你须要考虑这个异常出现以后是否调用者能够处理,而且你是否但愿调用者进行处理,若是调用者能够处理,而且你也但愿调用者进行处理,那么就要抛出受检异常,提醒调用者在使用你的方法时,考虑到若是抛出异常时若是进行处理。spring
类似的,若是在写某个方法时,你认为这是个偶然异常,理论上说,你以为运行时可能会碰到什么问题,而这些问题也许不是必然发生的,也不须要调用者显示的经过异常来判断业务流程操做的,那么这时就可使用一个 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. 以下:
public void test() { String path = "/a/b.png"; if(null != path && !"".equals(path)){ myTest.getFiles(""); } } public File getFiles(String path) { if(null == path || "".equals(path)){ throw new NullPointerException("路径不能为空!"); } File file = new File(path); return file; }
经过以上的描述和举例,能够总结出一个结论,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 工具包,他能够减小不少没必要要的工做量。咱们只须要把重心放到业务逻辑上。而不会由于这些入参的判断耽误更多的时间。
根据项目场景来看,须要两个 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() 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 等方法,用起来很是方便,感兴趣的读者能够自行了解一下。
数据链接层,咱们使用了 spring-data-jpa 这个框架,它要求咱们只须要继承框架提供的接口,而且按照约定对方法进行取名,就能够完成咱们想要的数据库操做。
用户数据库操做以下:
@Repository public interface IUserDao extends JpaRepository<User,Integer> { }
收货地址操做以下:
@Repository public interface IAddressDao extends JpaRepository<Address,Integer> { }
正如读者所看到的,咱们的 DAO 只须要继承 JpaRepository, 它就已经帮咱们完成了基本的 CURD 等操做,若是想了解更多关于 spring-data 的这个项目,请参考一下 spring 的官方文档,它比不方案咱们对异常的研究。
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); }
咱们来关注一下实现:
首先再来看一下以前整理的约束条件:
入参:
约束:
先看如下代码实现:
@Override public 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)。
介绍如下以上我所用到的技术:
一、Preconfitions.checkNotNull(T t) 这个是使用 Guava 中的 com.google.common.base.Preconditions 进行判断的,由于 service 中用到的验证较多,因此建议将 Preconfitions 改为静态导入的方式:
import static com.google.common.base.Preconditions.checkNotNull;
固然 Guava 的 github 中的说明也建议咱们这样使用。
二、BeanValidators.validateWithException(validator, address);
这个使用了 hibernate 实现的 jsr 303 规范来作的,须要传入一个 validator 和一个须要验证的实体, 那么 validator 是如何获取的呢, 以下:
@Configuration public 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() 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 层的修改,代码更改以下:
@Override public 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 设计以下:
@Override public 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.
根据不一样的业务需求抛出不一样的异常。
获取收货地址列表:
入参:
约束:
代码以下:
@Override public 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(); }
大体有两种抛出的方法:
这个是在设计 service 层异常时提到的,经过对 service 层的介绍,咱们在 service 层抛出异常时选择了第二种抛出的方式,不一样的是,在 api 层抛出异常咱们须要使用这两种方式进行抛出: 要指定 api 异常的类型,而且要指定相关的状态码,而后才将异常抛出,这种异常设计的核心是让调用 api 的使用者更能清楚的了解发生异常的详细信息。
除了抛出异常外,咱们还须要将状态码对应的异常详细信息以及异常有可能发生的问题制做成一个对应的表展现给用户,方便用户的查询。(如 github 提供的 api 文档,微信提供的 api 文档等), 还有一个好处: 若是用户须要自定义提示消息,能够根据返回的状态码进行提示的修改。
首先对于 api 的设计来讲,须要存在一个 dto 对象,这个对象负责和调用者进行数据的沟通和传递,而后 dto->domain 在传给 service 进行操做,这一点必定要注意。
第二点,除了说道的 service 须要进行基础判断 (null 判断) 和 jsr 303 验证之外,一样的,api 层也须要进行相关的验证,若是验证不经过的话,直接返回给调用者,告知调用失败,不该该带着不合法的数据再进行对 service 的访问。
那么读者可能会有些迷惑,不是 service 已经进行验证了,为何 api 层还须要进行验证么?这里便设计到了一个概念: 编程中的墨菲定律,若是 api 层的数据验证疏忽了,那么有可能不合法数据就带到了 service 层,进而讲脏数据保存到了数据库。
因此缜密编程的核心是: 永远不要相信收到的数据是合法的。
设计 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 层会调用 service 层,而后来处理 service 中出现的全部异常,首先,须要保证一点,必定要让 api 层很是轻,基本上作成一个转发的功能就好 (接口参数,传递给 service 参数,返回给调用者数据, 这三个基本功能),而后就要在传递给 service 参数的那个方法调用上进行异常处理。
此处仅以添加地址为例:
@Autowired private 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 异常,这是经常使用的一种异常转化方式。类似删除收货地址和获取收货地址也相似这样处理,在此,不在赘述。
已经讲解了如何抛出异常和何如将 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 接口须要签名认证等问题,文中都不曾提到,本文的重心在于异常如何处理,因此读者只需关注涉及到异常相关的问题和处理方式就能够了。