-
Notifications
You must be signed in to change notification settings - Fork 0
Description
前言
H2是一个轻量级的关系型数据库,使用简单方便。和其它RDBMS相比,在访问模式上,H2除了支持嵌入式本地访问外,同样也支持Server端模式下的Cluster和Sharding;而在功能层面上,H2支持标准的SQL语法、基于MVStore存储引擎的完整ACID事务和MVCC、索引(B树索引、哈希索引)、存储过程&函数、数据备份&恢复、用户角色管理等特性。一句话总结,麻雀虽小五脏俱全。
本文涉及到的关键类
在讲解H2的其它高级特性之前,我们先从会话建立和数据库文件的创建开始,涉及到的几个核心类:
- org.h2.Driver:数据库驱动类
- org.h2.jdbc.JdbcConnection:上层会话连接类
- org.h2.engine.Session:JdbcConnection持有的真正底层会话
- org.h2.engine.SessionRemote:远程会话连接
- org.h2.engine.SessionLocal:本地会话连接
- org.h2.engine.Engine:负责打开、创建数据库、会话连接
- org.h2.engine.Database:数据库实例类
- org.h2.engine.ConnectionInfo:连接信息类
- org.h2.engine.DbSettings:数据库全局配置参数类
驱动注册
在JDBC3的时候,我们必须使用Class.forName来显式加载数据库驱动,而从JDBC4(>=JDK6)开始,各家数据库厂商都开始支持基于SPI的方式加载数据库驱动,因此就没有必要再显式的通过Class.forName进行加载。
H2的META-INF/services/java.sql.Driver文件配置:
org.h2.Driver程序中,当我们通过JDBC的DriverManager#getConnection方法获取数据库连接时,会优先执行DriverManager的static代码块,初始化阶段会调用DriverManager#loadInitialDrivers方法基于SPI的方式加载所有数据库驱动类。
DriverManager#loadInitialDrivers的核心代码块:
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
// 通过ServiceLoader加载所有的数据库驱动
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
Iterator<Driver> driversIterator = loadedDrivers.iterator();
try{
while(driversIterator.hasNext()) {
// 触发数据库驱动的静态代码块执行注册
driversIterator.next();
}
} catch(Throwable t) {
// Do nothing
}
return null;
}
});这里还是简单说一下,ServiceLoader#iterator方法返回的Iterator为LazyIterator,它继承自java.util.Iterator,在执行其next()方法获取迭代器中的下一个元素时,其内部执行了Class.forname和newInstance()方法,这里会触发实例化动作,也就是调用驱动类的static代码块将驱动注册到DriverManager上。在org.h2.Driver的static代码块中,会调用其load函数执行数据库驱动的注册操作。
public static synchronized Driver load() {
try {
// 如果没有注册过则注册
if (!registered) {
// 标记为【已注册】
registered = true;
// 将自身注册到JDBC的DriverManager中
DriverManager.registerDriver(INSTANCE);
}
} catch (SQLException e) {
DbException.traceThrowable(e);
}
return INSTANCE;
}如果驱动已经被注册到DriverManager上,后续则不会再次注册。我们再切回到DriverManager的getConnection方法中,DriverManager在基于SPI执行完前置的驱动注册操作后,就会开始依次遍历注册项,找到符合条件的数据库驱动创建数据库连接。
// 遍历所有数据库驱动
for(DriverInfo aDriver : registeredDrivers) {
// 检查目标驱动类是不是由指定的ClassLoader装载的
if(isDriverAllowed(aDriver.driver, callerCL)) {
try {
println(" trying " + aDriver.driver.getClass().getName());
// 基于数据库驱动获取数据库连接
Connection con = aDriver.driver.connect(url, info);
if (con != null) {
// Success!
println("getConnection returning " + aDriver.driver.getClass().getName());
return (con);
}
} catch (SQLException ex) {
if (reason == null) {
reason = ex;
}
}
} else {
println(" skipping: " + aDriver.getClass().getName());
}
}不同数据库的URL是不同的,这所以这么设计,就是因为不同的驱动类,在创建连接前,都需要根据URL来判断到底是不是自己所支持的,总不可能Mysql的URL让Oracle去创建连接吧。至于如何判断,几乎所有的数据库驱动类都是根据URL的前缀来判断的,比如Mysql的URL前缀是“jdbc:mysql:”,而H2的URL前缀是“jdbc:h2:”。
public Connection connect(String url, Properties info) throws SQLException {
if (url == null) {// 如果url为null则异常
throw DbException.getJdbcSQLException(ErrorCode.URL_FORMAT_ERROR_2, null, Constants.URL_FORMAT, null);
}
// 前缀判断,h2的前缀为:jdbc:h2:
// DriverManager在调用任何的driver获取连接前,都会先进行url的前缀检查,如果不是目标前缀,则返回null
else if (url.startsWith(Constants.START_URL)) {
return new JdbcConnection(url, info, null, null, false);
}
// 只能基于嵌入式模式下,用在存储过程中,需要先执行org.h2.Driver.setDefaultConnection(conn);
else if (url.equals(DEFAULT_URL)) {
return DEFAULT_CONNECTION.get();
} else {
// 找不到前缀匹配的url,则返回null,DriverManager会抛SQLException("No suitable driver found for "+ url, "08001");异常
return null;
}
}如果传递进来的URL为null,则抛出异常;而不被H2所支持时,则会返回null,而当DriverManager在遍历完所有的驱动类都没有成功创建数据库连接后,将会抛出如下异常:
throw new SQLException("No suitable driver found for "+ url, "08001");在H2中还有一种和Oracle相同的缺省URL,既jdbc:default:connection,这一般是用在存储过程或函数中的特殊URL,具体后面会讲到,再此不过多进行阐述。
连接信息&数据库配置项解析
回到org.h2.Driver的connect方法上,假设URL前缀符合H2标准,这个时候Driver类会负责构造一个org.h2.jdbc.JdbcConnection对象实例,这个类继承自java.sql.Connection,这里我们可以将其理解为暴露给消费方的上层会话连接,但是真正的底层连接并不是这个类,org.h2.jdbc.JdbcConnection仅仅只是对真实连接进行了封装,尤其持有真正的数据库连接对象(org.h2.engine.Session)。
org.h2.jdbc.JdbcConnection构造函数的核心逻辑:
public JdbcConnection(String url, Properties info, String user, Object password, boolean forbidCreation)
throws SQLException {
try {
// 将URL中的各项解析到ConnectionInfo中
ConnectionInfo ci = new ConnectionInfo(url, info, user, password);
// 检测forbidCreation是否为true,如果为true则记录
if (forbidCreation) {
ci.setProperty("FORBID_CREATION", "TRUE");
}
// 从JVM启动参数中获取h2.baseDir指定的数据库目录
// 如果设置了这个目录,在URL只需要配置数据库名称,自动补全全限定名
// 通过设置 h2.baseDir 系统属性,可以将所有相对路径的数据库文件放置在一个统一的根目录下。这有助于集中管理数据库文件,特别是在多数据库实例的环境中。
String baseDir = SysProperties.getBaseDir();
if (baseDir != null) {
ci.setBaseDir(baseDir);
}
//todo 获取远程或者本地(嵌入式)数据库连接
session = new SessionRemote(ci).connectEmbeddedOrServer(false);
this.user = ci.getUserName();
this.url = ci.getURL();
closeOld();// todo 移除旧的资源
// 将连接封装为CloseWatcher,并注册到CloseWatcher的集合中,等待后续关闭
watcher = CloseWatcher.register(this, session, keepOpenStackTrace);
} catch (Exception e) {
throw logAndConvert(e);
}
}在H2中,关于参数信息可以分为2部分,分别是URL中的连接信息,其次是数据库的配置信息。而连接信息中,主要就是URL、数据库的账号(USER)、密码(PASSWORD、PASSWORD_HASH)、认证领域(AUTHREALM)、数据库文件的加密类型(CIPHER)、连接超时时间(NETWORK_TIMEOUT)、分页大小(PAGE_SIZE),以及新老库的兼容性(OLD_INFORMATION_SCHEMA)参数等。这些参数我们有2种方式可以在获取连接的时候进行指定,一种是在URL中:
// 在URL中设置指定连接信息和数据库配置
DriverManager.getConnection("jdbc:h2:file:/Users/johngao/Desktop/Desktop/db/mydb;" +
"user=root;password=123456;ANALYZE_AUTO=1000;TIME ZONE=UTC");除了可以在URL中配置,我们还给DriverManager#getConnection方法传递一个Properties,在Properties中添加各类链接信息和数据库配置参数,如下所示:
conn = DriverManager.getConnection("jdbc:h2:file:/Users/johngao/Desktop/Desktop/db/mydb10;CIPHER=AES", new Properties() {{
put("user", "root");
// 如果使用CIPHER加密数据库,那么密码的格式为: 文件密码+空格+数据库密码
put("password", "123456 654321");
// todo 设置数据库标识符(表名、列名)全大写
put("DATABASE_TO_UPPER", "TRUE");
put("ANALYZE_AUTO","200");
}});关于H2的连接参数,具体可以参考下ConnectionInfo的static代码块,JVM会在ConnectionInfo初始化的时候,把所有H2的已知参数项全部添加到其内部的一个KNOWN_SETTINGS集合中,后续会以此为基准对URL中的各项参数进行合法性校验。而ConnectionInfo从名字大概都能够猜出来是用来干嘛的,实际上就是个POJO类,在构造JdbcConnection实例时,JdbcConnection的第一步就是创建一个ConnectionInfo对象用于解析和存储各项连接参数和数据库配置参数信息。
ConnectionInfo构造函数的核心逻辑:
public ConnectionInfo(String u, Properties info, String user, Object password) {
// 获取url的映射关系
u = remapURL(u);
originalURL = url = u;
// 这里再检查一遍前缀,看看映射关系的前缀是否符合要求
if (!u.startsWith(Constants.START_URL)) {
throw getFormatException();
}
if (info != null) {
// 从info中读取url中的各项参数至prop中(KEY转为大写字母),这里会对参数做合法性校验
readProperties(info);
}
// 如果ConnectionInfo入参的账号密码不为null则覆盖prop中的账号和密码
if (user != null) {
prop.put("USER", user);
}
if (password != null) {
prop.put("PASSWORD", password);
}
// 解析URL中的其他项,并添加到prop中,执行到这一步的时候,会把info和url中的参数全部解析到prop中
readSettingsFromURL();
// 设置时区
Object timeZoneName = prop.remove("TIME ZONE");
if (timeZoneName != null) {
timeZone = TimeZoneProvider.ofId(timeZoneName.toString());// 设置时区到ConnectionInfo上
}
// 从prop中删除user,并将账号信息记录在ConnectionINfo.user中
setUserName(removeProperty("USER", ""));
// 解析url,这里是去除jdbc:h2:前缀,并记录在成员变量name中,成员变量url保留原始信息,name是去除前缀的url信息
name = url.substring(Constants.START_URL.length());
// 从去除前缀的url信息中解析出实际的数据库地址(去掉file:前缀),name最后会指向实际的数据库地址
parseName();
// 解析文件密码和用户密码,并分别对文件密码和用户密码进行SHA-256的定长hash处理
convertPasswords();
// 省略其他逻辑
}在ConnectionInfo构造函数中,首先会调用remapURL方法获取URL的映射地址,这种情况一般是用不到的,这里我猜测是用来做一些主备切换的URL重定向,或者是环境切换之类的,因为在实际开发过程中,通常数据库的连接信息我们会放在配置中心。当然如果你需要使用这个功能,可以添加JVM的启动参数“h2.urlMap=/path”来指定映射文件(格式为Properties)。当然如果映射文件不存在的情况下,H2在整个生命周期内部则仍然是使用原始URL。
之前也说了,连接信息和数据库配置既可以在URL中指定,也可以在Properties中指定,甚至还可以混合编排。因此ConnectionInfo的构造函数会分别调用readProperties和readSettingsFromURL方法来依次解析各项参数。这里我们先从readProperties方法讲起。
ConnectionInfo#readProperties方法的核心逻辑:
private void readProperties(Properties info) {
Object[] list = info.keySet().toArray();
DbSettings s = null;
for (Object k : list) {
// 将info中的配置项(USER、PASSWORD)转换为英文大写
// 在JDBC的DriverManager中,info中的项都是英文小写
String key = StringUtils.toUpperEnglish(k.toString());
// 重复项检查,info中存在重复项则抛异常
if (prop.containsKey(key)) {
throw DbException.get(ErrorCode.DUPLICATE_PROPERTY_1, key);
}
// 从info中获取出value,后续用于设置到prop中
Object value = info.get(k);
// 检查连接信息中的各项是否是KNOWN_SETTINGS中的已知设置,如果在的话,添加到prop中
if (isKnownSetting(key)) {
prop.put(key, value);
} else {
if (s == null) {
// 获取数据库全局配置,这里的用处不大,仅仅只是初始化一个DbSettings出来判断项是不是包含在其中,后面创建数据库的时候会基于prop中属性重新初始化一遍DbSettings
s = getDbSettings();
}
// 检查info中的项是不是包含在数据库的全局配置中,比如DATABASE_TO_LOWER、DATABASE_TO_UPPER等配置是不会在KNOWN_SETTINGS中的
if (s.containsKey(key)) {
prop.put(key, value);
}
}
}
}在readProperties方法中,会遍历传递给DriverManager#getConnection方法的Properties,取出来所有的URL连接参数和数据库配置参数,并按照H2的规范,全部转换为大写的KEY存储在ConnectionInfo中的prop中(也是一个Properties对象),以便于其内部后续使用。对于Properties中的重复项会直接抛异常,而未知项,则过滤,这一点和解析URL时的策略不太一样,晚点会说。
关于是否是未知项,readProperties方法中的判断逻辑是,首先从KNOWN_SETTINGS中判断是否是连接信息,如果不是则调用org.h2.engine.DbSettings#containsKey去判断是否是数据库配置信息,DbSettings是H2定义的一个全局数据库配置参数,本质上也是一个POJO,后面在真正创建数据库的时候会用到,这里也不细讲。
之前讲了,连接H2数据库时的所有链接信息、数据库配置信息,是可以在URL和Properties文件中混合编排的,因此在解析完Properties文件后,接下来ConnectionInfo的构造函数就会调用readSettingsFromURL方法去解析URL中的各项。
ConnectionInfo#readSettingsFromURL方法的核心逻辑:
private void readSettingsFromURL() {
// 初始化默认数据库配置
DbSettings defaultSettings = DbSettings.DEFAULT;
// 查看url中是否包含多个配置项,通过符号;分割,index返回的是符号;的索引起始位
int idx = url.indexOf(';');
// url中包含多个配置项的时候进入这里
if (idx >= 0) {
// 读取url后面的配置项
String settings = url.substring(idx + 1);
// 读取url项
url = url.substring(0, idx);
// 声明未知配置项
String unknownSetting = null;
// 将url后面的其他配置项,比如账号和密码通过符号;进行分割,其实这里类似于string.split(";")
String[] list = StringUtils.arraySplit(settings, ';', false);
// 迭代其他项配置
for (String setting : list) {
if (setting.isEmpty()) {
continue;
}
// 配置项的格式如果不是key=value的形式,则抛出异常
int equal = setting.indexOf('=');
if (equal < 0) {
throw getFormatException();
}
// 分别解析出key和value
String value = setting.substring(equal + 1);
String key = setting.substring(0, equal);
// 将key转换为英文大写
key = StringUtils.toUpperEnglish(key);
// 检查key包含在KNOWN_SETTINGS中或者DbSettings中
if (isKnownSetting(key) || defaultSettings.containsKey(key)) {
// 在readProperties中会把info中的所有项加入到prop中
// url中可能会配置info中相同的项,所以这里会check相同的项值如果不同,则异常
String old = prop.getProperty(key);
if (old != null && !old.equals(value)) {
throw DbException.get(ErrorCode.DUPLICATE_PROPERTY_1, key);
}
prop.setProperty(key, value);
} else {
// 标记不存在的项
unknownSetting = key;
}
}
// 如果存在未知项则抛异常
// IGNORE_UNKNOWN_SETTINGS=false,未知项抛异常,反之忽略
if (unknownSetting != null //
&& !Utils.parseBoolean(prop.getProperty("IGNORE_UNKNOWN_SETTINGS"), false, false)) {
throw DbException.get(ErrorCode.UNSUPPORTED_SETTING_1, unknownSetting);
}
}
}这里先提一嘴,重复项,是允许同时出现在URL和Properties中的,只是Properties中不允许出现重复项而已,而如果URL和Properties中存在重复项时,不存在谁覆盖谁的问题,而是值必须保持一致,否则H2就会抛异常。
H2在解析URL中的项时,也是通过符号“;”来进行参数分隔的,如果不存在多项时,则退出,反之分隔完后就进行迭代,并按照KV形式依次解析这些内容。如果项之前已经解析过了,则检查V是否一致,不一致抛异常,这一点刚才已经讲过了,如果一致则跟ConnectionInfo#readProperties中的方式一样,存储在ConnectionInfo的prop中,以便于其内部后续使用。而针对未知项,在解析URL时并不会像之前那样直接过滤,而是根据参数IGNORE_UNKNOWN_SETTINGS的结果来判断是否抛异常。IGNORE_UNKNOWN_SETTINGS是H2中的一个连接参数,并非是数据库配置信息,当“IGNORE_UNKNOWN_SETTINGS=true”时则过滤未知项,反之抛异常。
当ConnectionInfo的构造函数执行完readProperties和readSettingsFromURL方法后,接下来就会设置数据库时区,以及从URL中解析出数据库文件的全限定名(比如将:“jdbc:h2:file:/Users/johngao/Desktop/Desktop/db/mydb”解析为:“/Users/johngao/Desktop/Desktop/db/mydb”)。最后对用户密码和数据库文件密码进行SHA-256的定长hash处理。
ConnectionInfo#convertPasswords方法的核心逻辑:
private void convertPasswords() {
// 删除prop中的原始密码,并保存身份认证密码到prop中,前提是:非远程或者为SSL连接,并且prop包含AUTHREALM(身份认证)和密码不为null时保存密码
char[] password = removePassword();
boolean passwordHash = removeProperty("PASSWORD_HASH", false);
// CIPHER 指定了数据库的加密方式,比如AES
// 如果使用了数据库加密则进来
if (getProperty("CIPHER", null) != null) {
// split password into (filePassword+' '+userPassword)
int space = -1;
// todo 使用空格分割文件密码和用户密码
for (int i = 0, len = password.length; i < len; i++) {
if (password[i] == ' ') {
space = i;
break;
}
}
// 如果指定了CIPHER,但密码之间没有通过空格隔开,则抛异常
if (space < 0) {
throw DbException.get(ErrorCode.WRONG_PASSWORD_FORMAT);
}
// 从原密码中解析出用户密码
char[] np = Arrays.copyOfRange(password, space + 1, password.length);
// 从原密码中解析出文件密码
char[] filePassword = Arrays.copyOf(password, space);
// 远密码中的所有元素填充为0
Arrays.fill(password, (char) 0);
// password等于用户密码
password = np;
// 将文件秘钥char[]转换为byte[]类型
fileEncryptionKey = FilePathEncrypt.getPasswordBytes(filePassword);
// 对文件密码进行SHA-256的定长hash处理
filePasswordHash = hashPassword(passwordHash, "file", filePassword);
}
// 对用户密码进行SHA-256的定长hash处理
userPasswordHash = hashPassword(passwordHash, user, password);
}如果我们在指定URL项时,没有指定CIPHER,H2缺省只会把用户密码进行SHA-256定长,而如果指定了CIPHER,比如”CIPHER=AES“,则用户密码和数据库文件密码都会进行SHA-256定长处理,password的格式则为文件密码+空格+用户密码。
指定参数CIPHER后,password的格式:
// 文件密码+空格+用户密码
password=123456 654321再次回到JdbcConnection的构造函数中,H2接下来会读取用户配置的”h2.baseDir“启动项,如果指定了,则URL中只需要配置数据库名即可,而无需配置完整的数据库全限定名,H2会自行将h2.baseDir中配置的目录和数据库名拼接为一个完整的数据库全限定名地址,这个操作一般会用在多数据库实例的环境中,有助于集中管理数据库文件。
会话创建&数据库创建
接下来,就是创建真正的数据库连接逻辑。在H2中,会话连接分为2类,如下所示:
- SessionRemote:远程连接
- SessionLocal:本地连接(嵌入式连接)
SessionRemote和SessionLocal父类都是Session,它代表着H2中的真正的底层数据库连接,JdbcConnection构造函数中创建连接的代码:
//todo 获取远程或者本地(嵌入式)数据库连接
session = new SessionRemote(ci).connectEmbeddedOrServer(false);SessionRemote和SessionLocal本质都是底层的会话连接,区别就是一个网络连接,一个是本地进程内连接,底层实现区别不大,网络连接,只是在本地连接上面套了层TCP协议,因此本文以SessionLocal为主,而想了解SessionRemote的,自行阅读源码或者其它文献,本文不再进行过多着墨。在SessionRemote的connectEmbeddedOrServer方法中,会进行会话类型判断,确认是走远程连接,还是本地连接,如果是本地连接,会执行Engine#createSession方法,Engine类的作用是负责打开、创建数据库、会话连接。
public static SessionLocal createSession(ConnectionInfo ci) {
try {
// 建立本地数据库会话连接
SessionLocal session = openSession(ci);
// 防暴力破解账号密码的手段,暴力破解账户密码时的delay等待,正确后重置delay时间并快速登录
validateUserAndPassword(true);// todo 验证用户和密码
return session;
} catch (DbException e) {
if (e.getErrorCode() == ErrorCode.WRONG_USER_OR_PASSWORD) {
// 减缓暴力破解攻击的速度,暴力破解账户密码时的delay等待,错得越多,delay等待时间越长
validateUserAndPassword(false);
}
throw e;
}
}createSession方法中,最终会调用Engine#openSession方法来创建会话和创建数据库文件。
Engine#openSession方法的核心逻辑:
private static SessionLocal openSession(ConnectionInfo ci) {
// prop中没有这些参数时,给初始值
// 缺省:建立连接的时候,如果数据库不存在则忽略
boolean ifExists = ci.removeProperty("IFEXISTS", false);
// 缺省:建立连接的时候,如果数据库不存在则自动创建
boolean forbidCreation = ci.removeProperty("FORBID_CREATION", false);
// 缺省:URL中存在未知项时,抛异常
boolean ignoreUnknownSetting = ci.removeProperty(
"IGNORE_UNKNOWN_SETTINGS", false);
// 获取数据库文件的加密类型
String cipher = ci.removeProperty("CIPHER", null);
// 获取建立连接时的初始化DDL或者DML语句
String init = ci.removeProperty("INIT", null);
// 声明SessionLocal
SessionLocal session;
long start = System.nanoTime();
for (; ; ) {
// 打开本地连接,这里传递ConnectionInfo、ifExists、forbidCreation、cipher等参数,要开始创建数据库了
session = openSession(ci, ifExists, forbidCreation, cipher);
if (session != null) {
break;
}
// 下面是数据库关闭时的等待时间
if (System.nanoTime() - start > DateTimeUtils.NANOS_PER_MINUTE) {
throw DbException.get(ErrorCode.DATABASE_ALREADY_OPEN_1,
"Waited for database closing longer than 1 minute");
}
try {
Thread.sleep(1);
} catch (InterruptedException e) {
throw DbException.get(ErrorCode.DATABASE_CALLED_AT_SHUTDOWN);
}
}
session.lock();// todo 连接加锁
try {
// 省略部分代码
TimeZoneProvider timeZone = ci.getTimeZone();// todo 获取时区
if (timeZone != null) {
session.setTimeZone(timeZone);// todo 从ConnectionInfo种获取时区给会话连接
}
// todo 获取URL中的初始化DML或DDL语句执行
if (init != null) {
try {
CommandInterface command = session.prepareLocal(init);
command.executeUpdate(null);
} catch (DbException e) {
if (!ignoreUnknownSetting) {
session.close();
throw e;
}
}
}
session.setAllowLiterals(false); // todo 是否允许在SQL中使用字面值,INSERT INTO tab (name) VALUES ('test')
session.commit(true); // todo 提交当前会话事务
} finally {
session.unlock();// todo 会话解锁
}
return session;
}在H2的URL连接参数中,FORBID_CREATION和IFEXISTS都是在数据库不存在的时候可以指定是否抛异常,只不过当”FORBID_CREATION=false“的时候,H2会判断如果数据库不存在则自动创建,而为true时抛异常;IFEXISTS是在连接数据库的时候,如果设置”IFEXISTS=true“时,当数据库不存在时则抛异常。
其实上面这个方法,主要就干了几件事,首先是获取了ConnectionInfo中的一些关键参数,然后调用重载方法创建数据库文件和连接,最后执行一些初始化语句,并提交事务,最后返回Session会话。而在重载的openSession方法中,也就是本文的核心部分了。
private static SessionLocal openSession(ConnectionInfo ci, boolean ifExists, boolean forbidCreation,
String cipher) {
String name = ci.getName();// 获取数据库的标准化名称,这里跟name对应,只是会做一些检查:数据库文件路径是不是绝对路径、数据库名称是不是空
Database database;
ci.removeProperty("NO_UPGRADE", false);// 缺省:高版本H2连接数据库时不自动升级数据库文件的版本格式
boolean openNew = ci.getProperty("OPEN_NEW", false);// 缺省:每次连接的时候不会创建一个新的数据库文件
boolean opened = false; // 是否已经打开过数据库,打开标记
User user = null;// 声明用户对象模型
DatabaseHolder databaseHolder; // 声明数据库实例管理器
if (!ci.isUnnamedInMemory()) {// 非未命名的内存数据库进入这里
synchronized (DATABASES) {
databaseHolder = DATABASES.computeIfAbsent(name, (key) -> new DatabaseHolder());// 从DATABASES(hashmap)中根据数据库名称获取数据库实例管理器
}
} else {
databaseHolder = new DatabaseHolder();// 内存数据库直接创建一个数据库实例管理器,不存map
}
synchronized (databaseHolder) {// 获取数据库实例管理器锁,互斥避免并发问题
database = databaseHolder.database;// 从数据库实例管理器中获取数据库实例
if (database == null || openNew) {// 如果数据库实例不存在或者openNew=true,则创建数据库实例
if (ci.isPersistent()) { // 检测是否是持久化链接
String p = ci.getProperty("MV_STORE");// 获取是否启动MVStore存储引擎
String fileName;
// 这里默认只有MV_STORE存储引擎,哪怕配置了MV_STORE=FALSE,也会启动MVStore存储引擎
if (p == null) {
fileName = name + Constants.SUFFIX_MV_FILE; // 数据库路径+.mv.db
if (!FileUtils.exists(fileName)) {// 检查数据库文件是否存在,新创建数据库文件时肯定不存在
throwNotFound(ifExists, forbidCreation, name);
fileName = name + Constants.SUFFIX_OLD_DATABASE_FILE;
if (FileUtils.exists(fileName)) {
throw DbException.getFileVersionError(fileName);
}
fileName = null;
}
} else {
fileName = name + Constants.SUFFIX_MV_FILE;
if (!FileUtils.exists(fileName)) {
throwNotFound(ifExists, forbidCreation, name);
fileName = null;
}
}
// 检查数据库名称是否为null,以及是否不可写
if (fileName != null && !FileUtils.canWrite(fileName)) {
ci.setProperty("ACCESS_MODE_DATA", "r");
}
} else {
throwNotFound(ifExists, forbidCreation, name);
}
// 这里会创建数据库文件
database = new Database(ci, cipher);
opened = true;
boolean found = false;
// 遍历数据库中的所有角色和用户
// 刚创建的数据库肯定是不存在的角色和用户的
for (RightOwner rightOwner : database.getAllUsersAndRoles()) {
if (rightOwner instanceof User) {// todo 使用instanceof判断是否存在用户对象
found = true;
break;
}
}
// 当数据库中是否不存在管理员用户时向数据库插入管理员用户
// 建库的时候会添加master user
if (!found) {
// users is the last thing we add, so if no user is around,
// the database is new (or not initialized correctly)
user = new User(database, database.allocateObjectId(), ci.getUserName(), false);
user.setAdmin(true);
user.setUserPasswordHash(ci.getUserPasswordHash());
// 当不存在管理员用户的时候,向数据库中添加一个管理员用户
database.setMasterUser(user);
}
// 数据库实例管理器指向创建的数据库实例
databaseHolder.database = database;
}
}
if (opened) {
database.opened();// 打开数据库
}
if (database.isClosing()) {
return null;
}
// 刚创建数据库文件的时候,会向数据库中添加管理员,所以不需要验证账号密码,user也不会为null
if (user == null) {
if (database.validateFilePasswordHash(cipher, ci.getFilePasswordHash())) {// 验证数据库文件的密码
if (ci.getProperty("AUTHREALM") == null) {// 获取认证领域,不存在认证领域的时候进入这里
// 根据账号从数据库中获取出user模型,然后验证用户密码
user = database.findUser(ci.getUserName());
if (user != null) {
if (!user.validateUserPasswordHash(ci.getUserPasswordHash())) {
user = null;
}
}
} else {
// 省略部分代码
}
}
// 如果数据库已打开,且当前用户不是管理员,则将数据库的事件监听器设置为 null,
// 即移除事件监听器。这样做的目的是因为普通用户没有权限监听数据库异常,只有管理员才有此权限。
if (opened && (user == null || !user.isAdmin())) {
// reset - because the user is not an admin, and has no
// right to listen to exceptions
database.setEventListener(null);
}
}
// 账号密码错误的时候会进到这里
if (user == null) {
DbException er = DbException.get(ErrorCode.WRONG_USER_OR_PASSWORD);
database.getTrace(Trace.DATABASE).error(er, "wrong user or password; user: \"" +
ci.getUserName() + "\"");
database.removeSession(null);
throw er;
}
//Prevent to set _PASSWORD
ci.cleanAuthenticationInfo();
checkClustering(ci, database);// 检查是否是集群模式
SessionLocal session = database.createSession(user, ci.getNetworkConnectionInfo());// 创建数据库的本地连接
if (session == null) {
// concurrently closing
return null;
}
// 如果没有显式设置,则不兼容老版本的信息模式
if (ci.getProperty("OLD_INFORMATION_SCHEMA", false)) {
session.setOldInformationSchema(true);// todo 设置不兼容老版本的信息模式
}
// 省略部分代码...
return session;
}Engine类是单例的,内部有一个DATABASES集合,用来存储非内存模式下的数据库实例对象,K为数据库文件的全限定名,V为DatabaseHolder,DatabaseHolder我们可以理解为数据库实例管理器,每一个数据库实例都有一个DatabaseHolder负责牵引。同时高版本的H2数据库,缺省只支持MVStore存储引擎。而在H2中,代表数据库的对象是org.h2.engine.Database,数据库文件的创建则是在初始化Database的时候。
在调用重载的openSession方法时,如果数据库文件还未被创建,则在构造Database对象时创建数据库文件,并向数据库文件中添加管理员账号,但数据库文件如果已经创建了,缺省是不会再次创建的,只会从数据库中取出用户模型,并进行账号密码校验,方然这里也有个例外,如果URL中将参数OPEN_NEW设置为true时,则默认每次连接数据库的时候都会创建一个新的数据库文件。数据库创建完后,接下来就是调用Database#createSession方法,创建和保存一个SessionLocal对象。
private SessionLocal createSession(User user) {
int id = ++nextSessionId;// todo 生成唯一数据库会话id
return new SessionLocal(this, user, id);
}H2解决防爆破的手段时不停的递增delay时间进行休眠,简而言之就是,错的越多,登录验证的等待时间就越长。
Engine#validateUserAndPassword方法的核心逻辑
private static void validateUserAndPassword(boolean correct) {
// 如果该系统属性不存在,则默认值为250。这个系统属性用于设置在使用错误的用户名或密码时,抛出异常之前的最小延迟时间(以毫秒为单位)。
// 该延迟用于减缓暴力破解攻击的速度。在成功登录后,延迟时间将重置为该值。如果登录不成功,延迟时间将翻倍,
// 直到达到DELAY_WRONG_PASSWORD_MAX。可以通过将该系统属性设置为0来禁用延迟。
int min = SysProperties.DELAY_WRONG_PASSWORD_MIN;
if (correct) {
// todo delay时间,缺省等于SysProperties.DELAY_WRONG_PASSWORD_MIN
long delay = WRONG_PASSWORD_DELAY;
// 能进来(delay > min),说明上一把是错的
if (delay > min && delay > 0) {
// the first correct password must be blocked,
// otherwise parallel attacks are possible
synchronized (Engine.class) {
// delay up to the last delay
// an attacker can't know how long it will be
// 用户密码正确后,还是需要sleep一段时间,只是这个时间比较短
// delay重设为0-250的随机数等待时间
delay = MathUtils.secureRandomInt((int) delay);
try {
Thread.sleep(delay);
} catch (InterruptedException e) {
// ignore
}
// 重设delay时间为250ms
WRONG_PASSWORD_DELAY = min;
}
}
}
// 密码错误进入这里
else {
// this method is not synchronized on the Engine, so that
// regular successful attempts are not blocked
synchronized (Engine.class) {
// 获取缺省的delay时间,250ms
long delay = WRONG_PASSWORD_DELAY;
// 获取最大的delay时间,默认为4000ms,如果DELAY_WRONG_PASSWORD_MAX被设置为<=0,则max=Integer.MAX_VALUE
int max = SysProperties.DELAY_WRONG_PASSWORD_MAX;
if (max <= 0) {
max = Integer.MAX_VALUE;
}
// 每错一次密码,delay时间按照倍数叠加
WRONG_PASSWORD_DELAY += WRONG_PASSWORD_DELAY;
// 如果缺省的delay时间>最大值,或者被手动改为<0时直接将delay时间设置为max
if (WRONG_PASSWORD_DELAY > max || WRONG_PASSWORD_DELAY < 0) {
WRONG_PASSWORD_DELAY = max;
}
if (min > 0) {
// a bit more to protect against timing attacks
// 生成一个随机的长整型数值,并取随机数的最后2位,然后将其加到delay变量上
delay += Math.abs(MathUtils.secureRandomLong() % 100);
try {
// 输错密码进行delay等待
Thread.sleep(delay);
} catch (InterruptedException e) {
// ignore
}
}
throw DbException.get(ErrorCode.WRONG_USER_OR_PASSWORD);
}
}
}在JdbcConnection构造函数的最后,还需要将连接与CloseWatcher对象绑定,以便于后续进行资源释放动作
