`
frank-liu
  • 浏览: 1665466 次
  • 性别: Icon_minigender_1
  • 来自: 北京
社区版块
存档分类
最新评论

jdbc数据访问的封装和演化

 
阅读更多

简介

    在使用传统jdbc的方式来访问数据的时候,我们会发现它本身的过程非常繁琐。在实际的使用过程中有太多小的细节要关注,这样就使得直接对它的使用效率非常低下。因此,为了实际项目中提高工作效率,我们需要提供一个抽象的基础来使得开发人员更多的去关注业务的实现。这里,我们结合spring对于将jdbc的使用到进一步抽象和提炼出一个基础类框架的过程做一个详细的讨论。

 

jdbc访问数据

    在最初学习使用jdbc的时候,相信不少人都体验过使用原生的jdbc api来访问数据库的种种不便。总体来说,我们需要考虑怎么获取jdbc连接,然后准备执行的语句,再根据执行的结果进行处理和解析,最后再关闭数据库连接。以如下的几段代码为例:

获取数据库连接:(以mysql为例)

public class JDBCExample {

  public static void main(String[] argv) {

	try {
		Class.forName("com.mysql.jdbc.Driver");
	} catch (ClassNotFoundException e) {
		System.out.println("Where is your MySQL JDBC Driver?");
		e.printStackTrace();
		return;
	}

	System.out.println("MySQL JDBC Driver Registered!");
	Connection connection = null;

	try {
		connection = DriverManager
		.getConnection("jdbc:mysql://localhost:3306/sampledb","root", "password");

	} catch (SQLException e) {
		System.out.println("Connection Failed! Check output console");
		e.printStackTrace();
		return;
	}
  }
}

 

执行sql语句:

private static void insertRecordIntoDbUserTable() throws SQLException {
		Connection dbConnection = null;
		Statement statement = null;
		String insertTableSQL = "INSERT INTO DBUSER"
				+ "(USER_ID, USERNAME, CREATED_BY, CREATED_DATE) " + "VALUES"
				+ "(1,'test','system', " + "to_date('"
				+ getCurrentTimeStamp() + "', 'yyyy/mm/dd hh24:mi:ss'))";
		try {
			dbConnection = getDBConnection();
			statement = dbConnection.createStatement();
			// execute insert SQL stetement
			statement.executeUpdate(insertTableSQL);

		} catch (SQLException e) {
			System.out.println(e.getMessage());
		} finally {
			if (statement != null) {
				statement.close();
			}
			if (dbConnection != null) {
				dbConnection.close();
			}
		}
	}

  从上面的代码我们就可以看到,为了执行一两条简单的sql语句,我们需要做非常多的准备和收尾工作。像获取连接,释放连接,判断各种异常情况什么的。不仅繁琐而且非常容易出错。那么,从我们实际工程中使用的情况来看,我们来具体分析一下这种写法存在的问题。 

 

存在的问题

1. 代码量太大。以查询sql语句为例,我们需要首先获取connection,声明Statement和ResultSet,然后为了访问得到的结果还要去遍历整个ResultSet。

 

2. 需要慎重的考虑对资源的清理。尤其是在出现错误或者异常的情况下,我们要保证连接资源被正确的释放了。为了保证这一点,需要进行很多琐碎的比较和判断。

 

3. 对SQLException的处理。因为SQLException属于checked exception。这就意味着在处理一些日常执行语句的时候,就必须要在方法里声明要么抛出相关的异常,要么捕捉相关的异常。这样就会带来一个问题,从使用的角度来说,我们不希望上层的使用方和底层的jdbc实现紧密耦合在一起。而如果我们任由这种异常抛出来的话,那么上层的实现里就必须被关联进来了。而如果事先捕捉处理了,也不是一个好的办法。因为jdbc里出错的情况有很多种,具体捕捉后该怎么处理呢?这也会成为一个问题。

 

4. 在前面直接使用jdbc的过程里,如果出问题了,系统只是笼统的抛一个SQLException出来,正如前面所说的。我们并不知道是具体哪里出问题了。我们很难直接得到导致问题的原因。

 

问题的分析与分解

    针对前面的问题,我们一个个的看过来。 

 

checked exception vs unched exception

    我们先看看异常相关的处理。我们通常都知道,在java的异常体系里,除了Error和RuntimeException这些类族下面的异常我们用来记录unchecked exception之外,其他继承自Exception下面的类通常为checked exception。而他们的一个大的差别在于checked exception我们是要求显式的在方法里声明的。这种要求有一个好处,就是我们在有可能出现异常而且应该处理的地方有明确的程序标记,可以有针对性的处理。但是不足的地方就是处理不好就会导致异常的传递,导致系统不同层级的耦合。

    而unchecked exception有一个典型的要求就是,它本身表示不需要程序来catch的异常,因为一般来说这种异常的出现表示系统出现比较严重的问题,没法通过程序自身来恢复和处理了。

    结合这里SQLException的情况,它的问题就在这里。我们不希望这种异常到处传播,同时,在出现了问题之后能够得到更加有针对性的异常错误信息。那么我们该怎么来处理呢?

    现在有的一种思路就是,将原有的SQLException针对不同的情况进行分门别类,对于不同种类的错误或者异常信息划分到不同的类别里。既然前面作为checked exception会导致它的不断传播,我们可以将它转换成unchecked exception。所以,我们这里就要做两件事,一个就是对于这些异常的分类定义,而且都需要定义为unchecked exception。另外一个就是实现从SQLException到我们定义的分类异常的转换。

     我们先来看对于目标异常类的定义,从spring里面的定义和分类来看,它将目标异常类定义为如下图的结构:

 

    在这个图中,定义了一个NestedRuntimeException,而通用的抽象类DataAccessException用来表示所有访问异常的基类。根据具体使用情况的分类,又基于它定义了4大类。

TransientDataAccessException:  暂时性的数据访问异常。表示之前数据访问出错之后,但是后续的重试操作却有可能在不需要外界干预下成功的这种情况下的异常类型。

 

NonTransientDataAccessException: 非暂时性的错误。主要用来表示如果当前访问操作出问题了,继续重试操作依然会出错。除非当前导致出错的问题根源得到解决。这种异常一般需要额外的干预。

 

RecoverableDataAccessException:  如果当前操作执行某些恢复的步骤使得之前失败的操作可以成功,或者是对整个事物的重试等情况下出现的异常。

 

ScriptException:  记录执行SQL脚本时候出现的异常。

    通过这种方式,所有jdbc访问相关的异常信息就被划分到这4个门类的异常里了。一般某些个具体的异常都是继承自这几个类或者他们的子类。

 

    既然前面已经定义好了异常的类族结构了,剩下的就是该怎么实现对SQLException的转换了。这里也定义了一个基于SQLExceptionTranslator类的一系列转换。详情如下图:

 

    在这个异常转换类族里,要实现的核心方法就是DataAccessException translate(String task, String sql, SQLException ex); 这里的不同子类针对不同方面的异常进行转换。主要为一下几个:

SQLErrorCodeSQLExceptionTranslator:  根据vendor特定的实现返回的error code来做转换。

SQLExceptionSubclassTranslator:主要分析jdbc 驱动器抛出的特定SQLException子类。

SQLStateSQLExceptionTranslator:根据SQLException返回值里的SQL state状态信息。主要是状态信息里前面两个数字位来分析转换。

 

任务拆分

    在解决了前面的异常归类处理和传染问题之后,还有一个很重要的问题,就是怎么样让jdbc的执行更加简洁。在之前的代码实现里,相信我们已经看到了一些可以改进的端倪。在每个需要执行的sql语句执行里,需要首先获取到连接数据库的connection。然后再根据不同的需要去执行不同的增删查改语句。在使用完之后需要再考虑对资源进行关闭。因此,在这里,我们就可能会考虑到一种重用的思路。既然获取和释放资源的过程是相同的,而我们这里唯一可能不同的就是不同的语句执行和结果映射,那么我们可以把这些相同的地方给提取出来。

    确实,在spring里,对于connection的管理和释放就是由专门的类DataSourceUtils来做。它里面包含了getConnection和releaseConnection两个静态方法。除了对connection的管理这块,其他的大部分功能就放在JdbcTemplate类里面了。总的来说,他们相关的类关系如下图:

 

 

这个图里包含了比较多的细节,我们针对它引用的不同类以及它的用途来一个个的分析。

 

PreparedStatementCreator

    PreparedStatementCreator主要是用来实现在给定一个java.sql.Connection参数的情况下,返回java.sql.PreparedStatement类型的结果。在我们执行一些应用的查询和更新的时候,需要提供一个sql语句并绑定对应的参数。这个时候就会用到PreparedStatement这个类型。PreparedStatementCreator接口的详细代码定义如下:

public interface PreparedStatementCreator {
	PreparedStatement createPreparedStatement(Connection con) throws SQLException;
}

    在实际框架中使用这个类的时候,我们需要类似如下的代码,实现PreparedStatementCreator接口,并返回PreparedStatement: 

 

PreparedStatementCreator psc = new PreparedStatementCreator() {
    public PreparedStatement createPreparedStatement(Connection conn) throws SQLException {
	PreparedStatement ps = conn.prepareStatement(
	    "SELECT seat_id AS id FROM available_seats WHERE " +
		"performance_id = ? AND price_band_id = ? " ) ;
	ps.setlnt(1, performanceld);
	ps.setlnt(2, seatType);
	return ps;
    }
}

    从框架的实现来说,因为创建PreparedStatement是特定于具体应用的,每个执行情况都不一样,所以最好由用户实现了再提供给框架。当然,这种实现方式 显得效率有点低。于是PreparedStatementCreatorFactory类作为一个帮助类,提供了一些方法使得构造PreparedStatement的过程更加简单高效一些。

 

RowCallbackHandler

    上述图里还有一个比较常用的依赖接口,就是RowCallbackHandler。这个接口主要用在每次查询等操作结束后,将返回的ResultSet结果里对应的每一列内容转换成应用里特定的类。相当于是做一个应用特定的映射处理。因为这也是特定于每个应用的,所以具体的实现也需要使用者来提供。

    这个接口的本身定义如下:

public interface RowCallbackHandler {
	void processRow(ResultSet rs) throws SQLException;
}

    在实际应用里,一个典型的示例用法如下:

 

RowCallbackHandler rch = new RowCallbackHandler() {
	public void processRow(ResultSet rs) throws SQLException {
		int seatld = rs .getlnt (1) ;
		list.add(new Integer (seatld));
	}
}

  

JdbcTemplate

    在整个流程的实现里,类JdbcTemplate才是整个业务的核心。从数据库的增删查改操作来说,它主要的几类实现方法就有execute, query, update, call,以及支持批量数据处理的batchUpdate等方法。我们针对每种方法的实现看一下。

execute:

execute重载的方法总的来说有如下几个:

 

public <T> T execute(ConnectionCallback<T> action) throws DataAccessException;

public <T> T execute(StatementCallback<T> action) throws DataAccessException;

public void execute(final String sql) throws DataAccessException;

public <T> T execute(PreparedStatementCreator psc, PreparedStatementCallback<T> action) throws DataAccessException;

public <T> T execute(String sql, PreparedStatementCallback<T> action) throws DataAccessException;

   

    这些方法分别针对不同情形的使用。比如说第一个方法用来处理原生的java.sql.Connection。而第二和第三个方法用来处理静态的sql statement。在有些sql的使用里,需要传入参数进行绑定处理。于是后面两个方法就用来处理preparedStatement。总的来说,这一系列的execute方法相当于一个通用的执行方法。所有的增删查改都可以通过这种方式来做。对于一些返回简单结果的方法来说,这也算是一个不错的选择。我们以第二个方法的详细实现为例,看一下它的实现细节:

 

@Override
public <T> T execute(StatementCallback<T> action) throws DataAccessException {
	Assert.notNull(action, "Callback object must not be null");

	Connection con = DataSourceUtils.getConnection(getDataSource());
	Statement stmt = null;
	try {
		Connection conToUse = con;
		if (this.nativeJdbcExtractor != null && this.nativeJdbcExtractor.isNativeConnectionNecessaryForNativeStatements()) {
			conToUse = this.nativeJdbcExtractor.getNativeConnection(con);
		}
		stmt = conToUse.createStatement();
		applyStatementSettings(stmt);
		Statement stmtToUse = stmt;
		if (this.nativeJdbcExtractor != null) {
			stmtToUse = this.nativeJdbcExtractor.getNativeStatement(stmt);
		}
		T result = action.doInStatement(stmtToUse);
		handleWarnings(stmt);
		return result;
	}
	catch (SQLException ex) {
		// Release Connection early, to avoid potential connection pool deadlock
		// in the case when the exception translator hasn't been initialized yet.
		JdbcUtils.closeStatement(stmt);
		stmt = null;
		DataSourceUtils.releaseConnection(con, getDataSource());
		con = null;
		throw getExceptionTranslator().translate("StatementCallback", getSql(action), ex);
	}
	finally {
		JdbcUtils.closeStatement(stmt);
		DataSourceUtils.releaseConnection(con, getDataSource());
	}
}

   这部分的代码看起来比较长,其实做的事情就比较简单了。首先通过DataSourceUtils来获得connection,然后在try块里创建statement并执行。如果执行过程中出错的话,在catch块里会用JdbcUtils关闭statement,并用exceptionTranslator将SQLException转换成特定的异常。finally块里主要通过JdbcUtils关闭statement,DataSourceUtils关闭连接。

 

query:

    在类的实现里,query的方法算是比较多的。其实查询的操作无非就是通过执行一些sql查询的语句来返回一系列的结果,然后通过一定的方式将返回的结果映射为期望的结果类型。常用的几个query方法如下:

 

public <T> T query(final String sql, final ResultSetExtractor<T> rse) throws DataAccessException;

public <T> T query(final String sql, final ResultSetExtractor<T> rse) throws DataAccessException;

public <T> List<T> query(String sql, RowMapper<T> rowMapper) throws DataAccessException;

public <T> T query(PreparedStatementCreator psc, final PreparedStatementSetter pss, final ResultSetExtractor<T> rse) throws DataAccessException;

    一个典型的query方法实现如下:

 

public <T> T query(
		PreparedStatementCreator psc, final PreparedStatementSetter pss, final ResultSetExtractor<T> rse)
		throws DataAccessException {

	Assert.notNull(rse, "ResultSetExtractor must not be null");
	logger.debug("Executing prepared SQL query");

	return execute(psc, new PreparedStatementCallback<T>() {
		@Override
		public T doInPreparedStatement(PreparedStatement ps) throws SQLException {
			ResultSet rs = null;
			try {
				if (pss != null) {
					pss.setValues(ps);
				}
				rs = ps.executeQuery();
				ResultSet rsToUse = rs;
				if (nativeJdbcExtractor != null) {
					rsToUse = nativeJdbcExtractor.getNativeResultSet(rs);
				}
				return rse.extractData(rsToUse);
			}
			finally {
				JdbcUtils.closeResultSet(rs);
				if (pss instanceof ParameterDisposer) {
					((ParameterDisposer) pss).cleanupParameters();
				}
			}
		}
	});
}

    我们可以看到,其实这个方法只是直接调用execute方法而已。对返回结果的处理是通过ResultSetExtractor<T> rse来做的。其他的query方法以及queryForObject等方法无非就是对query方法的包装和重用。

 

update:

    update方法主要的实现有如下几个,主要的定义方法如下:

 

public int update(final String sql) throws DataAccessException;

public int update(final PreparedStatementCreator psc, final KeyHolder generatedKeyHolder) throws DataAccessException;

protected int update(final PreparedStatementCreator psc, final PreparedStatementSetter pss) throws DataAccessException;

    其中典型的实现如下:

@Override
public int update(final String sql) throws DataAccessException {
	Assert.notNull(sql, "SQL must not be null");
	if (logger.isDebugEnabled()) {
		logger.debug("Executing SQL update [" + sql + "]");
	}
	class UpdateStatementCallback implements StatementCallback<Integer>, SqlProvider {
		@Override
		public Integer doInStatement(Statement stmt) throws SQLException {
			int rows = stmt.executeUpdate(sql);
			if (logger.isDebugEnabled()) {
				logger.debug("SQL update affected " + rows + " rows");
			}
			return rows;
		}
		@Override
		public String getSql() {
			return sql;
		}
	}
	return execute(new UpdateStatementCallback());
}

     在这个方法里,我们看到,其实又是对execute方法的重用。其他很多update方法则是重用了该方法。

     除了上述的几个主要方法以外,像batchUpdate等方法主要是通过statement的batchUpdate方法或者preparedStatement的executeBatch等方法实现数据的批量更新。限于篇幅,这里就不再赘述。有了上述的JdbcTemplate的支持,我们要实现一个简单的sql查询就比较简单了。如下为一个查询数据库的示例代码:

 

public List<Employee> findAll(){
	jdbcTemplate = new JdbcTemplate(dataSource);
	String sql = "SELECT * FROM EMPLOYEE";
 
	List<Employee> employees = new ArrayList<Employee>();
 
	List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql);
	for (Map row : rows) {
		Employee employee = new Employee();
		employee.setId(Integer.parseInt(String.valueOf(row.get("ID"))));
		employee.setName((String)row.get("NAME"));
		employee.setAge(Integer.parseInt(String.valueOf(row.get("AGE"))));
		employees.add(employee);
	}
 
	return employees;
}	

   在上述的代码实现里,基本上只需要提供一个sql的执行语句,再根据执行结果获取需要的内容就可以了。

    这样,JdbcTemplate的主要部分就讨论完了。

 

进一步的改进

    上述的设计改进确实带来了很大的便利,但是,在某些情况下我们会使用到jdbc statement 和ResultSet。另外,一些callback handler的使用也显得比较复杂。那么,我们有没有可能做一个更高层面的抽象使得实现某些功能更加简单呢?一种思路就是将所有的RDMBS操作都建模为可重用的线程安全的对象。这种思路有点类似于command pattern的设计模式。通过对每一种数据库操作都建立一个类的方式,这种方法的好处就是使得sql语句对于调用代码来说是完全隐藏起来的。而且这种表示数据库操作的对象也不会绑定到特定的某些个数据库上面。

    在spring里,根据这种思想提取出来的类图结构如下:

 

 

我们针对图中间的类结构进一步讨论。

RdbmsOperation

    RdbmsOperation是整个类族里的父类,它主要定义引用的JdbcTemplate, javax.sql.DataSource以及sql字符串并设置对应的变量绑定。 一旦这些被设定好之后,一个对应的RdbmsOperation被认为就需要编译。这里的编译指的具体的DataSource验证,sql语言验证等都结束。具体的编译行为根据子类的需要而不同。RdbmsOperation是对所有数据库操作的一个抽象,但是他本身并没有定义具体的数据库操作,而是由具体的子类添加对应的方法来实现。

    在RdbmsOperation的详细实现里,因为它实现了接口InitializeBean,在每次spring框架创建好RdbmsOperation对象之后,它的afterPropertiesSet方法会调用compile方法。compile方法的实现如下:

 

public final void compile() throws InvalidDataAccessApiUsageException {
    if (!isCompiled()) {
	if (getSql() == null) {
		throw new InvalidDataAccessApiUsageException("Property 'sql' is required");
	}
	try {
		this.jdbcTemplate.afterPropertiesSet();
	}
	catch (IllegalArgumentException ex) {
		throw new InvalidDataAccessApiUsageException(ex.getMessage());
	}

	compileInternal();
	this.compiled = true;

	if (logger.isDebugEnabled()) {
		logger.debug("RdbmsOperation with SQL [" + getSql() + "] compiled");
	}
    }
}

    这里的实现会调用一个抽象方法compileInternal(),在后续的子类里会提供自己特定的实现。 

    另外,RdbmsOperation也提供了validateParameters和validateNamedParameters这两个方法用来验证变量绑定。这些方法将被它的子类给重用。

 

SqlOperation

SqlOperation类主要用来表示基于sql语句的更新、查询等操作。这个类里主要根据声明好的sql语句变量来配置对应的preparedStatementFactory。所以这里的实现主要是对compileInternal()方法做了一个覆写,做一些基本的配置,并提供一个覆写时的hook,方便后续的实现做一些特定的操作。

 

SqlCall

和前面SqlOperation针对sql语句并绑定变量参数不同,这里SqlCall类主要针对存储过程类的执行和配置。这里主要对CallableStatementCreatorFactory这个用于存储过程类型的参数配置和验证。

 

StoredProcedure

    继承自SqlCall的StoredProcedure则相对比较简单,它就是为了执行存储过程提供一些方法的。虽说它声明为一个抽象类,但是基本的使用方法都定义到这里了。它最核心的方法就是调用存储过程的方法execute:

 

public Map<String, Object> execute(Object... inParams) {
	Map<String, Object> paramsToUse = new HashMap<String, Object>();
	validateParameters(inParams);
	int i = 0;
	for (SqlParameter sqlParameter : getDeclaredParameters()) {
		if (sqlParameter.isInputValueProvided()) {
			if (i < inParams.length) {
				paramsToUse.put(sqlParameter.getName(), inParams[i++]);
			}
		}
	}
	return getJdbcTemplate().call(newCallableStatementCreator(paramsToUse), getDeclaredParameters());
}

   从上述的代码里可以看到,它在得到声明的变量之后,主要做一个验证和配置,最后是通过jdbcTemplate来调用具体的存储过程。而继承自它的类GenericStoredProcedure则是一个空的类。基本上就是只用使用StoredProcedure里的方法。

    使用存储过程的调用比较简单,一个简单的示例如下:

 

public void moveToHistoryTable(Person person) {
    StoredProcedure procedure = new GenericStoredProcedure();
    procedure.setDataSource(dataSource);
    procedure.setSql("MOVE_TO_HISTORY");
    procedure.setFunction(false);

    SqlParameter[] parameters = {
            new SqlParameter(Types.BIGINT),
            new SqlOutParameter("status_out", Types.BOOLEAN)
    };

    procedure.setParameters(parameters);
    procedure.compile();

    Map<String, Object> result = procedure.execute(person.getId());
}

   我们可以看到,只要声明了存储过程之后,将对应的输入参数配置好,调用它的编译和执行方法就可以了。 

 

SqlUpdate

    对于所有的sql更新语句来说,它有一个共同的特点,就是更新数据之后,我们只需要知道更新的数据行数就可以了。一般也不需要知道数据是怎么获取和怎么映射的。所以它的实现主要是通过update方法。一个典型的实现如下:

 

public int update(Object... params) throws DataAccessException {
	validateParameters(params);
	int rowsAffected = getJdbcTemplate().update(newPreparedStatementCreator(params));
	checkRowsAffected(rowsAffected);
	return rowsAffected;
}

   可见,这里本质上只是将构建输入参数的过程进一步封装了一起来,然后通过调用jdbcTemplate里的update方法实现更新。

 

SqlQuery

    像SqlQuery之类的过程和前面的稍微有点不一样。不管我们怎么封装,它的结果返回回来后需要映射到用户特定的对象这一步是没法省略了。但是那些配置对应查询参数的过程我们可以进一步的封装。像SqlQuery有一个典型的execute方法就是通过设定好rowMapper之后,得到一个结果集的:

 

public List<T> execute(Object[] params, Map<?, ?> context) throws DataAccessException {
	validateParameters(params);
	RowMapper<T> rowMapper = newRowMapper(params, context);
	return getJdbcTemplate().query(newPreparedStatementCreator(params), rowMapper);
}

    这里通过映射的字段来构造映射关系。在使用的时候可以更加简单一点。 继承自SqlQuery的各种实现主要是提供了一些使得rowMapper构造更加便利的手段,或者方便对象获取的手法。这些都使得开发使用的效率得到一定程度的提升。针对每个类的具体使用手法就不再赘述了。

 

总结

    直接使用jdbc的api来访问数据库存在着非常多的问题,包括数据库连接的获取和释放,sql语句的执行,参数的配置和绑定,以及结果的解析,还有异常的处理等等。针对这一系列的问题,spring框架里提供了几个优化的地方。一个是对连接资源专门的管理和释放,使得使用者不用去关心这些小的细节。对于异常进行转换和分类,通过将checked exception转换为unchecked exception,使得这些异常不会污染到上层的调用而导致系统的紧密耦合。

    另外,在spring里提供了两个层面的抽象。一个是基于JdbcTemplate类族的定义。所有的数据增删查改操作都可以根据它里面提供的方法来操作。另外,通过借鉴JDO的思想,将各种sql操作定义成对象的方式使得它的使用和操作更加高效,而避免了直接使用JdbcTemplate里一些参数绑定和数据映射的细节。这些改动进一步提高了使用的效率。spring里面这种数据操作抽象和封装的思想也是值得深入学习的。

 

参考材料

http://www.mkyong.com/jdbc/how-to-connect-to-mysql-with-jdbc-driver-java/

http://www.mkyong.com/jdbc/jdbc-statement-example-insert-a-record/

https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/dao/DataAccessException.html

Expert One-on-One j2EE design and development

  • 大小: 58.7 KB
  • 大小: 55.8 KB
  • 大小: 127.4 KB
  • 大小: 94.3 KB
分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics