第13章 统一的数据访问异常层次体系

本章内容

  • DAO模式的背景

  • 梦想照进现实

  • 发现问题,解决问题

  • 不重新发明轮子

要了解Spring为什么要提供统一的数据访问异常层次体系,我们得先从DAO模式说起。

DAO模式的背景

不管是一个逻辑简单的小软件系统,还是一个关系复杂的大软件系统,都可能涉及对相关数据的访问和存储,而这些数据的存储机制和访问方式往往随场景不同而各异。

为了统一和简化相关的数据访问操作,J2EE核心模式提出了DAO(Data Access Object,数据访问对象)模式。使用DAO模式,可以完全分离数据的访问和存储,很好地屏蔽了各种数据访问方式的差异性。

不论数据存储在普通的文本文件或者csv文件,还是关系数据库(RDBMS)或者LDAP(Light weight Directory Access Protocol,轻量级目录访问协议)系统中,使用DAO模式访问数据的客户端代码可以完全忽视这种差异,而以统一的接口来访问相应数据。

空话多说无益,我们还是来看一个具体的应用DAO模式的场景吧!对于大部分软件系统来说,访问顾客信息是大家经常接触的。我们就以访问顾客信息为例,看一下如何使用DAO模式。

使用DAO模式访问顾客数据,首先需要声明一个数据访问对象接口定义,如下代码所示:

 
 
    public interface ICustomerDao {
      Customer findCustomerByPK(String customerId);
      void updateCustomerStatus(Customer customer);
      // ....
    }

对于客户端代码,即通常的服务层代码来说,只需要声明依赖的DAO接口即可,即使数据访问方式方式发生了改变,只需要改变相关的DAO实现方式,客户端代码不需要做任何的调整。

依赖DAO接口的CustomerService类定义:

 
 
 
    public class CustomerService {
      private ICustomerDao customerDao;
 
      // 禁用客户活动
    	public void disableCustomerCampain(String customerId) {
            Customer customer = getCustomerDao().findCustomerByPK(customerId);
            customer.setCampainStatus(CampainStatus.DISABLE);
            getCustomerDao().updateCustomerStatus(customer);
      }
 
      public ICustomerDao getCustomerDao() {
            return cus tomerDao;
      }
 
      public void setCustomerDao(ICustomerDao customerDao) {
        this.customerDao = customerDao;
      }
    }
 

通常情况下,顾客信息存储在关系数据库中,所以,相应的我们会提供一个基于JDBC的DAO接口实现类:

 
    public class JDBCCustomerDao implements ICustomerDao {
    	@Override
    	public Customer findCustomerByPK(String customerId) {
    		// TODO Auto-generated method stub
    		return null;
    	}
 
    	@Override
    	public void updateCustomerStatus(Customer customer)	{
    		// TODO Auto-generated method stub
    	}
    }
 

可能随着系统需求的变更,顾客信息需要转移到LDAP服务,这时就需要提供一个基于LDAP的数据访问对象,如下所示:

 
    public class LdapCustomerDao implements ICustomerDao {
    	@Override
    	public Customer findCustomerByPK(String customerId) {
    		// TODO Auto-generated method stub
    		return null;
    	}
 
    	@Override
    	public void updateCustomerStatus(Customer customer)	{
    		// TODO Auto-generated method stub
    	}
    }

即使数据访问接口的实现类随着需求而发生变化,客户端代码(这里是CustomerService)也可以完全忽视这种变化,唯一需要变动的地方可能只是Factory对象的几行代码,甚至只是IoC容器配置文件中简单的class类型替换而已,而客户端代码无需任何变动。

所以,DAO模式对屏蔽不同数据访问机制的差异性起到举足轻重的作用。

梦想照进现实

为了简化概念的描述,前面针对DAO的例子省略了部分细节,比如接口与实现之间的某种依赖性。不管是JdbcCustomerDao还是LdapCustomerDao,都省略了最基本的东西,即 数据访问机制特定的代码 。当引入这些特定于数据访问机制的代码时,问题就产生了,最明显的莫过于特定于数据访问机制的 异常处理

当把具体的JDBC代码充实到JdbcCustomerDao时(见下方代码),看看哪里不对头。

 
 
    public Customer findCustomerByPK(String customerId) {
      Connection con = null;
      try {
        con = getDataSource().getConnection();
        //...
        Customer cust = ...;
 
        return cust;
      } catch (SQLException e) {
        // 是抛出呢?还是在当前位置处理呢?
      } finally {
        releaseConnection(con);
      }
    }
 
    private void releaseConnection(Connection con) {
      ...
    }
 

使用JDBC进行数据库访问,当其间出现问题的时候,JDBC API会抛出SQLException来表明问题的发生。而SQLException属于checked exception,所以,我们的DAO实现类要捕获这种异常并处理。

那如何处理DAO中捕获的SQLException呢,直接在DAO实现类处理掉?如果这样的话,客户端代码就无法得知在数据访问期间发生了什么变化?所以只好将SQLException抛给客户端,进而,DAO实现类的相应的签名也需要修正为抛出SQLException,如下所示:

 
    public Customer findCustomerByPK(String customerId) throws SQLException {
      ...
    }

相应的,DAO接口中的相应的方法签名也需要修改:

 
 
 
    public interface ICustomerDao {
      Customer findCustomerByPK(String customerId) throws SQLException;
      void updateCustomerStatus(Customer customer);
      // ....
    }

但是,这样并没有解决问题:

  • 问题1

我们的数据访问接口对于客户端来说是通用的,不管数据访问对象因为数据访问机制的不同而如何变更,客户端代码不应该受其牵连。但是,现在因为使用JDBC做数据访问,需要抛出特定的SQLException,那么 客户端代码就需要捕捉该异常 并做相应的处理。这是与数据访问对象模式的设计初衷相背离的。

  • 问题2

在引入另一种数据访问机制时,问题更是接踵而来。当加入LdapCustomerDao实现时,LdapCustomerDao需要抛出NamingException。如果要保证findCustomerByPK方法实现了ICustomerDao中的方法,那么就得更改ICustomerDao的方法签名,如下所示:

 
 
        public Customer findCustomerByPK(String customerId) throws NamingException {
      ...
    }

糟糕不是吗?我们又把统一的访问接口给改了,相应的客户端代码又要捕捉NamingException做相应的处理。如果随着不同数据访问对象实现的增多,以及考虑数据访问对象中其他数据访问方法,这种糟糕的情况不得继续下去吗?

也就是说, 因为数据访问机制的不同,我们的数据访问接口的定义现在变成了空中楼阁,我们无法最终定义并确定这个接口 ,不是吗?

发现问题,解决问题

问题出现了,我们就应该尝试解决问题,因为我们实在不忍舍弃DAO模式所描述的那幅场景。那么如何来避免以上问题呢?

(1)既然直接在DAO实现类内部处理SQLException这条路走不通,而将SQLException直接抛出又不可行,那么将SQLException或者其他特定的数据访问异常进行封装后再抛出又会如何?如果要这么做,以什么类型的异常进行封装然后再抛出呢?是checked exception还是unchecked exception?

大部分的或者说所有的数据访问操作抛出的异常对于客户端来说是系统的 Fault,客户端是无法有效处理的,比如数据库操作失败、无法取得相应资源等。 客户端对这些情况最有效的处理方式就是不做处理。因为客户端代码对于系统的Fault通常无法处理(当然如果必要,捕捉后处理也是可以的,比如捕捉相应异常后重试等),所以,将SQLException以及其他特定于数据访问机制的异常,以unchecked exception进行封装然后抛出,是比较合适的。 因为unchecked exception不需要编译器检查,ICustomerDao的数据访问方法就可恢复其本来面目而实现“大同”,如下所示:


    Customer findCustomerByPK(String customerId);

各个DAO实现类内部,只要将SQLException及其他特定的数据访问异常,以unchecked exception进行封装即可,如下方代码所示。

 
 
    public Customer findCustomerByPK(String customerId) {
      Connection con = null;
      try {
        con = getDataSource().getConnection();
        //...
        Customer cust = ...;
 
        return cust;
      } catch (SQLException e) {
        	throw new RuntimeException(e);
      } finally {
        releaseConnection(con);
      }
    }
 
 

现在,统一数据访问接口定义的问题解决了。

(2)以RuntimeException形式将特定的数据访问异常转换后抛出,虽然解决了统一数据访问接口的问题,但是,该方案依然不够周全。以SQLException为例,各个数据库提供商通过SQLException表达具体的错误信息时,所采用的方式是不同的,比如,有的数据库提供商采用SQLException的ErrorCode作为具体的错误信息标准,有的数据库提供商则通过SQLException的SqlState来返回详细的错误信息。

即使将SQLException封装后抛出给客户端对象,当客户端对象需要了解具体的错误信息时,依然需要根据数据库提供商的不同,采取不同的信息提取方式。要知道,将这种错误信息的具体处理分散到各个客户端对象中处理是何等的糟糕?我们应该向客户端对象屏蔽这种差异性!那么,如何来屏蔽这种差异性呢?

答案当然是异常的 分类转译 (Exception Translation)。

a)首先,不应该将对特定的数据访问异常的错误信息提取工作留给客户端对象,而是应该由DAO实现类,或者某个工具类以统一的方式进行处理。我们暂且让具体的DAO实现类来做这个工作,那么对于JdbcCustomerDao来说,捕获异常后的处理就如下方代码所示。

 
    try {
      //...
    } catch(SQLException e) {
      if (isMysqlVendor()) {
        // 按照Mysql数据库的规则分析错误信息(e)然后抛出
        throw new RuntimeException(e);
      }
      if (isOracleVendor()) {
        // 按照Oracle数据库的规则分析错误信息(e)然后抛出
        throw new RuntimeException(e);
      }
      ...
    }
 

b)信息是提取出来了,可是,只通过RuntimeException一个异常类型,还不足以区分不同的错误类型,我们需要将数据访问期间发生的错误进行分类,然后为具体的错误分类,分配一个对应的异常类型。

比如,数据库连接不上、ldap服务器连接失败,我们认为它们同属于获取资源失败;而主键冲突或者其他资源冲突,我们认为它们属于数据一致性冲突。

那么,针对这些情况,就可以以RuntimeException为基准,为获取资源失败这种情况分配一个RuntimeException的子类型,称其为ResourceFailureException,而数据一致性冲突则可以对应RuntimeException的另一个子类型DataIntegrityviolationException,其他的分类和异常类型以此类推,这样,就有了如下方代码所示的异常处理逻辑。

 
 
    try {
      //...
    } catch(SQLException e) {
      if (isMysqlVendor()) {
        if (1 == e.getErrorCode()) {
          throw new ResourceFailureException(e);
        } else if (1062 == e.getErrorCode()) {
          throw new DataIntegrityviolationException(e);
        } else {
          ...
        }
      }
 
      if (isOracleVendor()) {
        int[] resourceFailureCodes = {17002,17447};
    		int[] dataIntegrationViolationCode = {1,1400,1722,2291};
        ...
        if (ArrayUtils.contains(resourceFailureCodes, e.getErrorCode())) {
          throw new ResourceFailureException(e);
        } else if (ArrayUtils.contains(dataIntegrationViolationCode, e.getErrorCode())) {
          throw new DataIntegrityviolationException(e);
        } else {
          ...
        }
      }
    }
 
 

不论采用的是什么数据库服务器,也不论采用的是什么数据访问方式,除了示例所提到的基于JDBC的数据访问方式,还有对于其他的数据访问方式,只要将它们自身的异常,通过某种方式转译为以上提到的这几种异常类型,对于客户端对象来说,只需要关注这几种类型的异常,就可以知道到底出了什么问题,甚至系统监控人员也可以直接根据日志信息判断问题之所在。

说到底,在一套语义完整的异常体系定义完成之后,不管数据访问方式如何变换,只要相应的数据访问方式能够将自身的异常,转译到这套语义完整的异常体系定义之内,对于客户端对象来说,自身的数据访问异常处理逻辑从此就是岿然不动的。

实际上,我们需要的只是一套unchecked exception类型的面向数据访问领域的异常层次体系。

不重新发明轮子

现在,我们知道了unchecked exception类型的面向数据访问领域的异常层次体系存在的必要性,马上着手设计和实现它?不,我们已经有了现成的“轮子”啦!

那就是 Spring提供的数据访问异常层次体系

Spring框架中统一的异常层次体系所涉及的大部分异常类型都定义在org.springframework.dao包中,处于这个体系的所有异常类型均以org.springframework.dao.DataAccessException为“统领”,然后根据职能划分为不同的异常子类型,总体上看,整个的异常层次体系如图13-1所示。

img

关于这些异常的职责不做介绍,有兴趣可以看原文

本章小结

Spring提供的统一的数据访问异常层次体系,在整个Spring数据访问层中起到了提纲挈领的作用。

在本章中,我们一起分析了这个数据访问异常层次体系出现的背景以及其演化过程,最后给出了针对它的详细介绍。

在各位已经对Spring的数据访问异常层次体系了如指掌之后,接下来我们将一起来了解Spring数据访问层的另一个主要特色,即JDBC API的最佳实践。