Skip to content

RDBMS-H2源码剖析-连接&DB创建部分 #7

@gaoxianglong

Description

@gaoxianglong

前言

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");

图1为JDBC中驱动注册的整体交互流程,如下所示。
image

图1 驱动注册流程

在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对象绑定,以便于后续进行资源释放动作

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions