CodeIgniter源码分析 7.1 - 数据库驱动

本节我们看下数据库驱动相关的源码,本质上来说数据库驱动其实就是对适配器模式的应用而已:在抽象层统一好暴露给外界的接口,在驱动内部封装差异化的细节

CodeIgniter源码分析 7.1 - 数据库驱动_第1张图片
image.png

CI框架数据库驱动的架构是由这几部分构成的:

  • 数据库连接,源码位于 DB.php 中。
  • 缓存处理,源码位于 DB_cache.php 中。
  • 驱动抽象层,源码位于 DB_driver.php 中。
  • 用来建表的数据库工厂,源码位于 DB_forge.php 中。
  • 查询构造器,源码位于 DB_query_builder.php 中。
  • 查询结果处理,源码位于 DB_result.php 中。
  • 工具函数,源码位于 DB_utility.php 中。
  • 以及一些驱动器,位于 systems/database/drivers 下。
CodeIgniter源码分析 7.1 - 数据库驱动_第2张图片
image.png

由于这部分的源码太多,我们选择性的将一些关键点的源码分析一下,只分析驱动 mysqli 的情况,主要围绕如下几点:

  • 驱动是如何适配的?
  • 数据库是怎么连接上的?连接是怎么处理的?
  • 原生的 sql 语句是怎么处理的?
  • 为什么要有查询构造器?查询构造器是什么封装的?
  • 事务是怎么封装的?事务的自动提交与回滚又是怎么做的?
  • 数据库缓存的是sql还是sql结果集?


驱动加载

$this->load->database() 可以实现连接数据库;位于加载器 Loader.php 中 ,该函数可以支持通过加载器自动载入(也就是自动连接数据库,之前在加载器源码分析中看到过),也可以支持手动调用实现数据库连接。

/*
     * $param 参数是可选的,它是数据库配置,如果没传,那么它就会读取配置文件 database.php 中的配置。
     * $return 决定是否返回数据库对象,该参数的意义在于当你手动连接多个数据库时,你就不能再通过$this->db
     *       获取数据库对象了,因为$this->db不知道你要引用哪个数据库,你只能在该函数中将数据库返回。
     * $query_builder 决定是否启用查询构造器,因为在一些类库的函数中依赖查询构造器访问数据库;该参数会覆盖配置文件中的 query_builder。
     * */
    public function database($params = '', $return = FALSE, $query_builder = NULL)
    {
        // Grab the super object
        $CI =& get_instance();

        // 检测数据库是否已经连接,如果已经连接,直接返回
        if ($return === FALSE && $query_builder === NULL && isset($CI->db) && is_object($CI->db) && ! empty($CI->db->conn_id))
        {
            return FALSE;
        }

        //真正的数据库连接是在 DB.php 中
        require_once(BASEPATH.'database/DB.php');

        if ($return === TRUE)
        {
            return DB($params, $query_builder);
        }

        //数据库连接成功后挂在全局对象$CI(也就是$this)上
        $CI->db = '';

        $CI->db =& DB($params, $query_builder);
        return $this;
    }

上面的代码中我们看到,数据库对象是从 DB.php 中生成的,该文件中我们将会看到是如何读取数据库配置以及适配驱动的。

function &DB($params = '', $query_builder_override = NULL)
    {
        // 判断是否通过参数传进了 dsn 配置,如果没有就要读配置文件
        if (is_string($params) && strpos($params, '://') === FALSE)
        {
            //判断配文文件是否存在
            if ( ! file_exists($file_path = APPPATH.'config/'.ENVIRONMENT.'/database.php')
                && ! file_exists($file_path = APPPATH.'config/database.php'))
            {
                show_error('The configuration file database.php does not exist.');
            }

            //加载配置文件
            include($file_path);

            //如果指定了要加载的目录,还得去这些目录下加载
            if (class_exists('CI_Controller', FALSE))
            {
                foreach (get_instance()->load->get_package_paths() as $path)
                {
                    if ($path !== APPPATH)
                    {
                        if (file_exists($file_path = $path.'config/'.ENVIRONMENT.'/database.php'))
                        {
                            include($file_path);
                        }
                        elseif (file_exists($file_path = $path.'config/database.php'))
                        {
                            include($file_path);
                        }
                    }
                }
            }

            //检车配置文件中的配置项是否存在
            if ( ! isset($db) OR count($db) === 0)
            {
                show_error('No database connection settings were found in the database config file.');
            }

            //param参数有可能是空,也有可能是某个配置组的key,如果param不为空,说明传进来的是某个配置组的key,
            //后面就需要根据这个key选择特定的配置组
            if ($params !== '')
            {
                $active_group = $params;
            }

            //检测$active_group 作为 key 的配置组是否存在
            if ( ! isset($active_group))
            {
                show_error('You have not specified a database connection group via $active_group in your config/database.php file.');
            }
            elseif ( ! isset($db[$active_group]))
            {
                show_error('You have specified an invalid database connection group ('.$active_group.') in your config/database.php file.');
            }

            $params = $db[$active_group];
        }
        //通过参数传进了 dsn 格式的配置
        elseif (is_string($params))
        {
            //解析 dsn,失败的话就报错,说明是非法的连接字符串
            if (($dsn = @parse_url($params)) === FALSE)
            {
                show_error('Invalid DB Connection String');
            }

            //这里为什么又要讲 dsn 中的参数解析到 $param 中是因为默认读取的配置文件中的配置以及通过手动方式传入的配置都是
            //数组的格式,为了方便后续的处理,将配置都同一成数组格式;这里还需注意:用 rawurldecode() 而非 urldecode()
            //的原因是因为前者不会将加号 + 解析成空格
            $params = array(
                'dbdriver'  => $dsn['scheme'],
                'hostname'  => isset($dsn['host']) ? rawurldecode($dsn['host']) : '',
                'port'      => isset($dsn['port']) ? rawurldecode($dsn['port']) : '',
                'username'  => isset($dsn['user']) ? rawurldecode($dsn['user']) : '',
                'password'  => isset($dsn['pass']) ? rawurldecode($dsn['pass']) : '',
                'database'  => isset($dsn['path']) ? rawurldecode(substr($dsn['path'], 1)) : ''
            );

            // 解析 url 中的查询字符串
            if (isset($dsn['query']))
            {
                parse_str($dsn['query'], $extra);

                foreach ($extra as $key => $val)
                {
                    if (is_string($val) && in_array(strtoupper($val), array('TRUE', 'FALSE', 'NULL')))
                    {
                        $val = var_export($val, TRUE);
                    }

                    $params[$key] = $val;
                }
            }
        }

        // 如果既没通过参数传入配置,也没从配置文件读到配置,那么就报错
        if (empty($params['dbdriver']))
        {
            show_error('You have not selected a database type to connect to.');
        }

        // 是否启用查询构造器
        if ($query_builder_override !== NULL)
        {
            $query_builder = $query_builder_override;
        }
        // Backwards compatibility work-around for keeping the
        // $active_record config variable working. Should be
        // removed in v3.1
        elseif ( ! isset($query_builder) && isset($active_record))
        {
            $query_builder = $active_record;
        }

        //载入驱动抽象层,该类中规范了所有驱动对外的一致接口
        require_once(BASEPATH.'database/DB_driver.php');

        //判断是否启用查询构造器,然后将抽象驱动层挂到 CI_DB 上,后面的各种驱动只需要继承 CI_DB 统一对外行为就行了,
        //注意: CI_DB_query_builder 继承了 CI_DB_driver,所以这里的 CI_DB 可看做是它两的别名
        if ( ! isset($query_builder) OR $query_builder === TRUE)
        {
            require_once(BASEPATH.'database/DB_query_builder.php');
            if ( ! class_exists('CI_DB', FALSE))
            {
                /**
                 * CI_DB
                 *
                 * Acts as an alias for both CI_DB_driver and CI_DB_query_builder.
                 *
                 * @see CI_DB_query_builder
                 * @see CI_DB_driver
                 */
                class CI_DB extends CI_DB_query_builder { }
            }
        }
        elseif ( ! class_exists('CI_DB', FALSE))
        {
            /**
             * @ignore
             */
            class CI_DB extends CI_DB_driver { }
        }


        //读取配置中的驱动,然后载入该驱动
        $driver_file = BASEPATH.'database/drivers/'.$params['dbdriver'].'/'.$params['dbdriver'].'_driver.php';

        file_exists($driver_file) OR show_error('Invalid DB driver');
        require_once($driver_file);

        // 实例化该驱动
        $driver = 'CI_DB_'.$params['dbdriver'].'_driver';
        $DB = new $driver($params);

        //处理驱动是 pdo 的情况,由于各个数据库语法有不同的地方(主要是一些获取元数据的语法),所以将 pdo 又做了一层抽象,在它下面又划分了一些子驱动
        if ( ! empty($DB->subdriver))
        {
            $driver_file = BASEPATH.'database/drivers/'.$DB->dbdriver.'/subdrivers/'.$DB->dbdriver.'_'.$DB->subdriver.'_driver.php';

            if (file_exists($driver_file))
            {
                require_once($driver_file);
                $driver = 'CI_DB_'.$DB->dbdriver.'_'.$DB->subdriver.'_driver';
                $DB = new $driver($params);
            }
        }

        //初始化,在该方法中进行数据库连接
        $DB->initialize();
        return $DB;

通过分析这段代码我们看出驱动的适配是通过读取配置文件,进而加载相关的驱动,驱动实例化后通过调用了 $DB->initialize() 方法实现了数据连接 。


数据库连接

我们知道数据库连接是在 initialize()方法中实现的,接下来通过学习该方法的源码你会看到如何处理数据库连接,以及我们经常看到的一个字眼 高可用

public function initialize()
    {
        //判断是否已经连接上了,是的话,直接返回
        if ($this->conn_id)
        {
            return TRUE;
        }

        // ----------------------------------------------------------------

        /*
         * 连接数据库
         *
         * 注意:
         * 接下来这行代码体现出了面向对象的深邃之处,initialize() 是在驱动抽象层的,
         * 而当驱动继承了驱动抽象层,这里的 db_connect() 则是调用驱动自己的 db_connect();
         * 参数pconnect决定是否为持久连接,持久连接可以提升效率,但是持久连接不会在脚本
         * 结束后释放连接,如果连接数太多的时候会导致资源占用,影响别的客户端。
         *
         * */
        $this->conn_id = $this->db_connect($this->pconnect);

        // 连接的数据库,但是发现此连接失败,那就检测是否设置了故障转移(高可用),
        // 故障转移是在配置文件的 failove 字段上设置的,
        // 具体的故障转移配置见:https://codeigniter.org.cn/user_guide/database/configuration.html
        if ( ! $this->conn_id)
        {
            // Check if there is a failover set
            if ( ! empty($this->failover) && is_array($this->failover))
            {
                // Go over all the failovers
                foreach ($this->failover as $failover)
                {
                    // Replace the current settings with those of the failover
                    foreach ($failover as $key => $val)
                    {
                        $this->$key = $val;
                    }

                    // Try to connect
                    $this->conn_id = $this->db_connect($this->pconnect);

                    // If a connection is made break the foreach loop
                    if ($this->conn_id)
                    {
                        break;
                    }
                }
            }

            // 如果使用持久化连接以后依然失败,那么报错
            if ( ! $this->conn_id)
            {
                log_message('error', 'Unable to connect to the database');

                if ($this->db_debug)
                {
                    $this->display_error('db_unable_to_connect');
                }

                return FALSE;
            }
        }

        // 最后设置字符编码
        return $this->db_set_charset($this->char_set);
    }

这段代码中我们看到了所谓的高可用方案,通过在配置文件 database.php 设置 failover 实现的,并且做了持久化连接的设置。
同时可看到真正的数据库连接是下沉到各个驱动中去的,因此我们才会看到了驱动实现了各自的 db_connect() 方法。

驱动中的数据库连接就很简单了,解析连接参数 ,然后调用相关的接口进行数据库连接罢了;

public function db_connect($persistent = FALSE)
{
        //判断是否为socket连接
        if ($this->hostname[0] === '/')
        {
            $hostname = NULL;
            $port = NULL;
            $socket = $this->hostname;
        }
        else
        {
            //非socket连接,根据参数配置选择是否持久连接,注意:持久化连接仅在php5.3以上支持
            $hostname = ($persistent === TRUE && is_php('5.3'))
                ? 'p:'.$this->hostname : $this->hostname;
            $port = empty($this->port) ? NULL : $this->port;
            $socket = NULL;
        }

        //接下来就是连接前的一些参数设置:超时时间,客户端压缩之类的
        $client_flags = ($this->compress === TRUE) ? MYSQLI_CLIENT_COMPRESS : 0;
        $mysqli = mysqli_init();

        $mysqli->options(MYSQLI_OPT_CONNECT_TIMEOUT, 10);

        if ($this->stricton)
        {
            $mysqli->options(MYSQLI_INIT_COMMAND, 'SET SESSION sql_mode="STRICT_ALL_TABLES"');
        }

        return $mysqli->real_connect($hostname, $this->username, $this->password, $this->database, $port, $socket, $client_flags)
            ? $mysqli : FALSE;
}

整个数据库连接的过程就分析结束了, 数据库连上后,接下来就是和操作数据库了,先看下原生的 sql 语句是怎么被处理的。


原生 sql 语句处理

CI框架处理原生 sql 查询的方法是 DB_driver.php 中的query方法,在该方法中你会看到对 sql 处理大致如下

首先判断 sql 的行为,原生的 sql 有两种行为,要么是 read,要么是 write,所以处理这两种行为的返回结果是不一样的,前者返回结果集,后者返回操作状态!

对于 read 行为并且启用了查询缓存,则尝试从缓存中读取结果;如果没有开启缓存,则通过 simple_query() 获取结果集对象并挂在 result_id 上,当结果集对象获取以后会扔给相应驱动的 DB_result 对象做一层数据访问的封装,我们使用的 result_array(),row_array() 等获取查询结果的函数就是在 DB_result 中封装的!

最后将结果集对象(DB_result) 返回。

同时也会看到一些细节化的处理,比如

sql 执行失败事务该怎么办?
基准测试的埋点是怎么埋的?
为什么 last_query() 能够获取到最近执行的一条 sql?

那现在进入 query 方法看下具体实现。

public function query($sql, $binds = FALSE, $return_object = NULL)
    {
        //sql检查,判断是否为空,并且获取sql的操作类型 read 还是 write
        if ($sql === '')
        {
            log_message('error', 'Invalid query: '.$sql);
            return ($this->db_debug) ? $this->display_error('db_invalid_query') : FALSE;
        }
        elseif ( ! is_bool($return_object))
        {
            $return_object = ! $this->is_write_type($sql);
        }

        // Verify table prefix and replace if necessary
        if ($this->dbprefix !== '' && $this->swap_pre !== '' && $this->dbprefix !== $this->swap_pre)
        {
            $sql = preg_replace('/(\W)'.$this->swap_pre.'(\S+?)/', '\\1'.$this->dbprefix.'\\2', $sql);
        }

        // 解析参数绑定
        if ($binds !== FALSE)
        {
            $sql = $this->compile_binds($sql, $binds);
        }

        // 如果是读类型的sql,则先尝试从缓存中读取
        if ($this->cache_on === TRUE && $return_object === TRUE && $this->_cache_init())
        {
            $this->load_rdriver();
            if (FALSE !== ($cache = $this->CACHE->read($sql)))
            {
                return $cache;
            }
        }

        // 记录执行过的sql,last_query()就是从该数组中获得最近一条sql的
        if ($this->save_queries === TRUE)
        {
            $this->queries[] = $sql;
        }

        $time_start = microtime(TRUE);

        //=================================================================================
         //注意:这段逻辑有点复杂
         /*
         * 执行sql,将结果集对象挂到result_id(result_id 会用在 DB_result 中封装一层获取数据的方法);
         * 如果开启了事务但是执行失败,则回滚事务,由于事务存在嵌套的可能,_trans_depth 字段就是嵌套的层级,通过 _trans_depth
         * 一层一层的回滚事务;最后打印出sql执行出错的原因。
         * */
        if (FALSE === ($this->result_id = $this->simple_query($sql)))
        {
            if ($this->save_queries === TRUE)
            {
                $this->query_times[] = 0;
            }

            // This will trigger a rollback if transactions are being used
            $this->_trans_status = FALSE;

            // Grab the error now, as we might run some additional queries before displaying the error
            $error = $this->error();

            // Log errors
            log_message('error', 'Query error: '.$error['message'].' - Invalid query: '.$sql);

            if ($this->db_debug)
            {
                // We call this function in order to roll-back queries
                // if transactions are enabled. If we don't call this here
                // the error message will trigger an exit, causing the
                // transactions to remain in limbo.
                if ($this->_trans_depth !== 0)
                {
                    do
                    {
                        $this->trans_complete();
                    }
                    while ($this->_trans_depth !== 0);
                }

                // Display errors
                return $this->display_error(array('Error Number: '.$error['code'], $error['message'], $sql));
            }

            return FALSE;
        }
        //=============================================================================================

        // Stop and aggregate the query time results
        $time_end = microtime(TRUE);
        $this->benchmark += $time_end - $time_start;

        if ($this->save_queries === TRUE)
        {
            $this->query_times[] = $time_end - $time_start;
        }

        // Increment the query counter
        $this->query_count++;

        // 如果读类型的 sql 并不要求返回结果,那么就要重置该 sql 对应的缓存
        if ($return_object !== TRUE)
        {
            if ($this->cache_on === TRUE && $this->cache_autodel === TRUE && $this->_cache_init())
            {
                $this->CACHE->delete();
            }

            return TRUE;
        }

        // 之前我们看到结果集对象是挂在 result_id 上,通过load_rdriver加载相应驱动 DB_result后,就可以将 $this 传给它,
        //在其内部获取 result_id 后,就可以封装出一层结果访问函数了,如 row_array(),result_array() 等等
        $driver     = $this->load_rdriver();
        $RES        = new $driver($this);

        //将结果集对象写入到缓存中,这里你可能会有疑问,$RES 已经是结果集对象了,为什么还要 new 一个结果集对象出来?
        //那是因为,当脚本执行完成后结果集要释放了,但如果缓存是引用了某个结果集(也就是result_id),势必会导致
        //该result_id无法释放,进而占用内存资源
        if ($this->cache_on === TRUE && $this->_cache_init())
        {
            $CR = new CI_DB_result($this);
            $CR->result_object  = $RES->result_object();
            $CR->result_array   = $RES->result_array();
            $CR->num_rows       = $RES->num_rows();

            // Reset these since cached objects can not utilize resource IDs.
            $CR->conn_id        = NULL;
            $CR->result_id      = NULL;

            $this->CACHE->write($sql, $CR);
        }
        
        //最后返回该结果集对象
        return $RES;
    }

load_rdriver()如下

 public function load_rdriver()
    {
        $driver = 'CI_DB_'.$this->dbdriver.'_result';
        
        //判断相应的 driver是否已经被加载,如果没有就需要加载进来
        if ( ! class_exists($driver, FALSE))
        {
            require_once(BASEPATH.'database/DB_result.php');
            require_once(BASEPATH.'database/drivers/'.$this->dbdriver.'/'.$this->dbdriver.'_result.php');
        }

        return $driver;
    }

我们说了获取结果集对象(result_id)后,会将该对象扔给 DB_result 对象封装出一层数据访问的方法,例如 row_array(),result_array() 等等,那么现在进入 DB_result 中看看是不是这样。

public function __construct(&$driver_object)
    {
        $this->conn_id = $driver_object->conn_id;
        $this->result_id = $driver_object->result_id;
    }

我们看到在构造方法中真的获取了结果集对象(result_id),看到 $driver_object 就是 query() 中 new driver($this) 时的数据库对象,可以看出整个数据库驱动的架构中责任划分的还是很清楚的。

接着往下翻,我们看到了 result_object()

public function result_object()
    {
        //如果result_object还在内存中,就直接返回,意味着做了相同的查询
        if (count($this->result_object) > 0)
        {
            return $this->result_object;
        }

        if ( ! $this->result_id OR $this->num_rows === 0)
        {
            return array();
        }
        
        // 如果 result_array 已经在内存中了,由于数组和对象的区别不是很大,直接通过array生成对象
        if (($c = count($this->result_array)) > 0)
        {
            for ($i = 0; $i < $c; $i++)
            {
                $this->result_object[$i] = (object) $this->result_array[$i];
            }

            return $this->result_object;
        }
        
        //如果内存中中没有我们要获取的数据,那就调用_fetch_object()获取结果,
        //_fetch_object()是在各个驱动器中实现的,因为每个驱动器的结果集对象(result_id)不一样
        is_null($this->row_data) OR $this->data_seek(0);
        while ($row = $this->_fetch_object())
        {
            $this->result_object[] = $row;
        }

        return $this->result_object;
    }

在该方法内部我们会看到其通过 _fetch_object() 获取了 sql 查询结果,而这个结果恰恰是通过结果集对象(result_id)获得的。

而 _fetch_object 其实就是对结果集对象(result_id)的包装

protected function _fetch_object($class_name = 'stdClass')
{
      return $this->result_id->fetch_object($class_name);
}

原生 sql 查询的源码分析就到此结束了,同时我们在分析 query() 方法的过程中看到了缓存的处理,那么现在也就能解答一开始我们关于缓存的疑问了。


查询缓存缓存的是sql还是结果集?

答:是结果集对象,结果集对象上持有了查询结果,而结果集对象被序列化后写入到以sql为文件名的文件中去的!

我们在 query() 看到结果集对象是通过 DB_cache 对象的 write() 方法写入缓存中的,具体的处理过程如下:

public function write($sql, $object)
{
        //可以看到缓存文件名是控制器,方法,以及md5后的sql组成的
        $segment_one = ($this->CI->uri->segment(1) == FALSE) ? 'default' : $this->CI->uri->segment(1);
        $segment_two = ($this->CI->uri->segment(2) == FALSE) ? 'index' : $this->CI->uri->segment(2);
        $dir_path = $this->db->cachedir.$segment_one.'+'.$segment_two.'/';
        $filename = md5($sql);

        if ( ! is_dir($dir_path) && ! @mkdir($dir_path, 0750))
        {
            return FALSE;
        }
        
        //看到没,结果集对象被序列化后写入到了缓存文件中去了
        if (write_file($dir_path.$filename, serialize($object)) === FALSE)
        {
            return FALSE;
        }

        chmod($dir_path.$filename, 0640);
        return TRUE;
}


我们知道 CI 框架还提供了一种让我们操作数据库的功能:查询构造器,查询构造器对于不想写原生 sql 的人来说是很方便的,同时还能避免sql注入,但是高层的方便意味着底层的复杂,下节我们看下查询构造器是怎么封装的!


你可能感兴趣的:(CodeIgniter源码分析 7.1 - 数据库驱动)