简明 JDBC 数据访问操作库:JdbcHelper(一)
开篇
综观后台开发,大多数的开发工作就是数据库的开发。怎么跟数据库打好交道与我们的开发体验息息相关。业界典型的讨论“对象关系阻抗不匹配”正是问题的症结所在:既然都是两种不同的“编程世界观”——一个数据库、一个编程语言,各自有各自的方法论、甚至思维都不太一样,怎么可以做到“和衷共济”呢?所以问题的关键就是如何解决这个“阻抗不匹配”难题——数据库模型和编程语言模型之间的差异所导致的“难题”。凭借多年的开发经验,笔者斗胆给出一个清晰、简单、可复用的 JDBC 解决方案,务求尝试可以处理数据库也就是 Java 领域中 JDBC 所围绕的“CRUD(增删改查)”问题。当然,“肺话(废话)”还是得先说一说,这只代表笔者本人的经验和水平,可能有考虑不周的情况,但容笔者再三审视,此乃算是比较通俗化、适谱性较强的方案,——当然咱不走偏锋,搞太多稀奇古怪的轮子。其中有些大家争论的地方,笔者看如今可以有个结论了:
- 承认 SQL 首要(SQL First)。很多后台开发者为了避开写 SQL,于是在各种 ORM 方案绞尽脑汁,笔者看大可不必。ORM 看似开发效率奇高,但实际“里三层、外三层”——不过封装罢了,终究还是回到 SQL 层,也就是说,写 ORM 搞不定,你还是要写 SQL
- 复杂逻辑的 SQL 不允许你写 ORM,或者说写 SQL 更舒服简单,为什么要弄蹩脚的 ORM 呢?实际 SQL 本身足够优秀,不就写多种语言嘛。为什么开发者只能用一种语言呢?你看前端的,虽然主打 js/ts,但人家 HTML/CSS 也不拉下呀——可见多语言开发是常态,不必排斥学习
- 业界也给出最终的答案,当时 iBatis/MyBatis 代替了 Hibernate,即是一例
综上述,就是要 SQL 与编程语言分离。如何分离呢?怎么界定好两者之间的接口正是问题关键所在。在 Java 中,大家尝试用 DAO(Data Access Object)层去解决。我们这里讨论的,就是这个 DAO。
前面说 MyBatis 的框架是最终答案,为什么不直接用就好呢?MyBatis 笔者也比较喜欢,也不是说反对使用 MyBatis,而是笔者希望有自己更简单的 DAO。没有接触 MyBatis 之前,笔者已经有自己几次尝试写 ORM 的累积,虽然不太完美,但也不想放弃,于是再继续复盘、重构、优化。因此在前几次的基础,再推出一次新的 JDBC DAO,——希望也是最后一次的方案。
源码揽胜
话不多说,让我们直接进入源码看看。源码在:https://gitee.com/sp42_admin/ajaxjs/tree/master/aj-backend/aj-framework/aj-framework/src/main/java/com/ajaxjs/data。
连接数据库
首先是处理数据库连接的,JdbcConn
,之前的版本都是静态方法,在 static 方法中传入 Connection 对象,Connection 太常见了,几乎每次都需要传入。这次重构改掉之前喜欢过程式的风格,采用 Java OO 方式(好像这不是什么问题,但对于“老人”来说习惯使然就是毛病)。
创建数据库连接
connection
作为 JdbcConn
的一个 field 出现。这意味着,通过 getter/setter 读写此 connection
,或者使用 Spring 的注入。
/**
* 数据库连接
*/
public class JdbcConn {
private static final LogHelper LOGGER = LogHelper.getLog(JdbcConn.class);
/**
* 数据库连接对象
*/
Connection connection;
……
}
JDBC 最质朴的连接数据库方法如下。有次试过不能把 user 和 password 写在第一个 jdbc 连接字符串上,那样会连不通,死活不行(估计要转义某个字符串),分开 user 和 password 就可以。
/**
* 连接数据库。这种方式最简单,但是没有经过数据库连接池。
* 有时不能把 user 和 password 写在第一个 jdbc 连接字符串上 那样会连不通
* 分开 user 和 password 就可以
*
* @param jdbcUrl 数据库连接字符串,不包含用户名和密码
* @param userName 用户
* @param password 密码
* @return 数据库连接对象
*/
public Connection getConnection(String jdbcUrl, String userName, String password) {
try {
if (StringUtils.hasText(userName) && StringUtils.hasText(password))
connection = DriverManager.getConnection(jdbcUrl, userName, password);
else connection = DriverManager.getConnection(jdbcUrl);
LOGGER.info("数据库连接成功: " + connection.getMetaData().getURL());
} catch (SQLException e) {
LOGGER.warning("数据库连接失败!", e);
}
return connection;
}
重载一个版本,数据库连接字符串,已包含用户名和密码的。
/**
* 连接数据库。这种方式最简单,但是没有经过数据库连接池。
*
* @param jdbcUrl 数据库连接字符串,已包含用户名和密码
* @return 数据库连接对象
*/
public Connection getConnection(String jdbcUrl) {
return getConnection(jdbcUrl, null, null);
}
这个最质朴的方法,注意是没有经过数据库连接池的,一般测试用或者简单场合用。
至于使用连接池的,笔者这里给出一个使用 Tomcat JDBC Pool 的,如 setupJdbcPool()
,
/**
* 手动创建连接池。这里使用了 Tomcat JDBC Pool
*
* @param driver 驱动程序,如 com.mysql.cj.jdbc.Driver
* @param url 数据库连接字符串
* @param userName 用户
* @param password 密码
* @return 数据源
*/
public static DataSource setupJdbcPool(String driver, String url, String userName, String password) {
PoolProperties p = new PoolProperties();
p.setDriverClassName(driver);
p.setUrl(url);
p.setUsername(userName);
p.setPassword(password);
p.setMaxActive(100);
p.setInitialSize(10);
p.setMaxWait(10000);
p.setMaxIdle(30);
p.setMinIdle(5);
p.setTestOnBorrow(true);
p.setTestWhileIdle(true);
p.setTestOnReturn(true);
p.setValidationInterval(18800);
p.setDefaultAutoCommit(true);
org.apache.tomcat.jdbc.pool.DataSource ds = new org.apache.tomcat.jdbc.pool.DataSource();
ds.setPoolProperties(p);
return ds;
}
大家都喜欢用 Druid、HikariCP,——但笔者比较喜欢轻量级的 Tomcat 的,相当于集成自带。
连接字符串说不定什么时候会用到,也记一个吧:
/**
* 一般情况用的数据库连接字符串
*/
public static final String JDBC_TPL = "jdbc:mysql://%s/%s?characterEncoding=utf-8&useSSL=false&autoReconnect=true&zeroDateTimeBehavior=convertToNull&rewriteBatchedStatements=true&serverTimezone=Asia/Shanghai";
还有个不得不提的是从 DataSource 获取 Connection 。DataSource 其实比 Connection 更重要,特点有三:
- 可以从 DataSource 获取 Connection(如下方法),是其根源;
- DataSource 一般代表已经池化了的;
- 于是这个 DataSource 才是 API 交换的接口(实际也是 Java Interface)。君不见如 Spring JDBC 也是要求你传入 DataSource 而不是 Connection 的,所以框架级的暴露一个数据库对象应该是 DataSource 而不是Connection。
/**
* 根据数据源对象获得数据库连接对象
*
* @param source 数据源对象
* @return 数据库连接对象
*/
public Connection getConnection(DataSource source) {
try {
connection = source.getConnection();
if (connection == null) LOGGER.warning("DataSource 不能建立数据库连接");
} catch (SQLException e) {
LOGGER.warning(e, "通过数据源对象获得数据库连接对象失败!");
}
return connection;
}
当前进程的数据库连接
这个就是日常使用的 Connection,每次控制器先会从这里获取数据库连接。大概就是通过 ThreadLocal
保存、获取,非常方便。
既然是 ThreadLocal 的——那就是 static 静态方法了。
/**
* 当前进程的数据库连接
*/
private static final ThreadLocal<Connection> CONNECTION = new ThreadLocal<>();
/**
* 获取一个当前进程的数据库连接
*
* @return 当前进程的数据库连接对象
*/
public static Connection getConnection() {
return CONNECTION.get();
}
/**
* 保存一个数据库连接对象到当前进程
*
* @param conn 当前进程的数据库连接对象
*/
public static void setConnection(Connection conn) {
CONNECTION.set(conn);
}
/**
* 关闭当前进程的数据库连接
*/
public static void closeDb() {
closeDb(getConnection());
CONNECTION.set(null);
}
/**
* 关闭数据库连接
*
* @param conn 数据库连接对象
*/
public static void closeDb(Connection conn) {
try {
if (conn != null && !conn.isClosed()) {
conn.close();
if (Version.isDebug)
LOGGER.info("关闭数据库连接成功! Closed database OK!");
}
} catch (SQLException e) {
LOGGER.warning(e);
}
}
顺带提供关闭数据库连接的静态方法。一般在控制器执行完业务方法之后统一关闭。
JdbcReader vs. JdbcWriter
标题党,其实没什么好比较的,就是一个读、一个写,安排在两个类而已。它们都继承 JdbcConn。
JdbcReader
如下,executeQuery()
属于底层的方法,调用 PreparedStatement 进行查询。开始时候还想要封装原始的 Statement 对象,如今看没什么意义,一概使用 PreparedStatement 即可。
先明确一个事情,但凡与 DBMS 沟通,不消多说就是传入字符串的 SQL;在使用 PreparedStatement 的情况下,SQL 可以是带 占位符的 ?
的,于是有了另外一个参数:参数列表,可以是 Object[]
,传给 PreparedStatement 就行。下面我们无论 CRUD,都是这样的结构:SQL:String
和 params: Object[]
(可选,不传也行,表示只执行 SQL 即可)
/**
* 执行查询
*
* @param <T> 结果的类型
* @param processor 结果处理器
* @param sql SQL 语句
* @param params 插入到 SQL 中的参数,可单个可多个可不填
* @return 查询结果,如果为 null 表示没有数据
*/
public <T> T executeQuery(ResultSetProcessor<T> processor, String sql, Object... params) {
try (PreparedStatement ps = connection.prepareStatement(sql)) {
setParam2Ps(ps, params);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next())
return processor.process(rs);
else {
LOGGER.info("查询 SQL:{0} 没有符合的记录!", sql);
return null;
}
}
} catch (SQLException e) {
LOGGER.warning(e);
}
return null;
}
查询是否有数据这里是 rs.next()
,但好像不同数据库有不同的方法,SQLite 记得好像就不是了,先不管了。只考虑 MySQL 的情况。
ResultSetProcessor<T> processor
是一个自定义 lambda,用于如何转换 ResultSet 到目标结果的处理器。
import java.sql.ResultSet;
import java.sql.SQLException;
/**
* 如何转换 ResultSet 到目标结果的处理器
*
* @param <T> 结果的类型
*/
@FunctionalInterface
public interface ResultSetProcessor<T> {
/**
* 转换结果
*
* @param rs JDBC 结果集合
* @throws SQLException SQL 异常
*/
T process(ResultSet rs) throws SQLException;
}
当然你可以用 JDK 原生的 Function
类型的,无须自定义 lambda。之所以自定义 lambda,为了是可以确定参数 ResultSet rs
。
JDBC 的 ResultSet 不能直接使用,一般都是转换到目标类型,如 Map 或者 Java Bean,
/**
* 记录集合转换为 Map
*
* @param rs 记录集合
* @return Map 结果
* @throws SQLException 转换时的 SQL 异常
*/
public static Map<String, Object> getResultMap(ResultSet rs) throws SQLException {
// LinkedHashMap 是 HashMap 的一个子类,保存了记录的插入顺序
Map<String, Object> map = new LinkedHashMap<>();
ResultSetMetaData metaData = rs.getMetaData();
for (int i = 1; i <= metaData.getColumnCount(); i++) {// 遍历结果集
String key = JdbcUtil.changeColumnToFieldName(metaData.getColumnLabel(i));
Object value = rs.getObject(i);
map.put(key, value);
}
return map;
}
看上去这是普通的 Java static 方法,但也符合 ResultSetProcessor<T>
之定义,可作 lambda 用,于是 JdbcReader::getResultMap
这样是 OK 的,下面就是使用例子。
/**
* 查询单行记录(单个结果),保存为 Map<String, Object> 结构。如果查询不到任何数据返回 null。
*
* @param sql SQL 语句,可以带有 ? 的占位符
* @param params 插入到 SQL 中的参数,可单个可多个可不填
* @return Map<String, Object> 结构的结果。如果查询不到任何数据返回 null。
*/
public Map<String, Object> queryAsMap(String sql, Object... params) {
return executeQuery(JdbcReader::getResultMap, sql, params);
}
这方法只考虑单行记录查询的,至于列表则参考 queryAsMapList()
,篇幅关系不多说了。