vlambda博客
学习文章列表

【Laravel系列4.5】主从库配置和语法生成

主从库配置和语法生成

对于我们线上的运行环境来说,经常会有的一种情况就是需要主从分离。关于主从分离有什么好处,怎么配之类的内容不是我们学习框架的重点。但是你要知道的是,Laravel 以及现代化的所有框架都是可以方便地配置主从分离的。另外,我们还要再回去 查询构造器 中,看一下我们的原生 SQL 语句的拼装语法到底是如何生成的。

主从数据库连接

其实配置非常简单,我们先来简单的看一下。之后,我们再深入源码,看看它是怎么做到写入走主库,读取走从库的。

'mysql2' => [
    'driver' => 'mysql',
    'read' => [
        'host'=>[
            '192.168.56.101'
        ]
    ],
    'write' => [
        'host'=>[
            env('DB_HOST''127.0.0.1'),
        ]
    ],
    'url' => env('DATABASE_URL'),
//            'host' => env('DB_HOST', '127.0.0.1'),
    'port' => env('DB_PORT''3306'),
    'database' => env('DB_DATABASE''forge'),
    'username' => env('DB_USERNAME''forge'),
    'password' => env('DB_PASSWORD'''),
    'unix_socket' => env('DB_SOCKET'''),
    'sticky' => true,
    'charset' => 'utf8mb4',
    'collation' => 'utf8mb4_unicode_ci',
    'prefix' => '',
    'prefix_indexes' => true,
    'strict' => true,
    'engine' => null,
    'options' => extension_loaded('pdo_mysql') ? array_filter([
        PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
    ]) : [],
],

我们这里是修改的 config/database.php 文件。可以看到,和原始配置不同的是我们注释掉了原来的 hosts ,然后增加了 read 和 write ,在这两个属性里面可以以数组的形式指定 hosts 。这样,我们的查询语句和增删改语句就实现了分离,查询语句会走 read 的配置,而其它语句则会走 write 的配置。同时,我们还多增加了一个 sticky 并设置为 true 。它的作用是,在同一次的请求中,如果执行了增删改的操作,那么紧接着的查询也会走 write 也就是主库的查询。这也是因为我们在某些业务中,需要在操作完数据后马上查询,主从之间的延迟可能会导致查询的从库数据不正确(这在现实业务中很常见)。因此,在一次增删改操作后如果紧接着有查询的话,我们当前的这个请求流程还是会继续查询主库。

接下来,我们定义两个路由来测试。

Route::get('ms/test/insert'function(){
    \Illuminate\Support\Facades\DB::connection('mysql2')->table('db_test')->insert(['name'=>'Lily''sex'=>2]);
    dd( \Illuminate\Support\Facades\DB::connection('mysql2')->table('db_test')->get()->toArray());
});

Route::get('ms/test/list'function(){
    dd( \Illuminate\Support\Facades\DB::connection('mysql2')->table('db_test')->get()->toArray());
});

在执行第一个路由之后,dd() 打印的数据中我们会看到新添加成功的数据。接着去请求第二个路由,会发现数据还是原来的,并没有增加新的数据。因为我们并没有在 MySQL 配置主从同步,这也是为了方便我们的调试查看。很明显,第二个路由的查询语句走的就是另一个数据库了。

对于如何实现的读写分离,我们从 原生查询 的 select() 方法来看。找到 laravel/framework/src/Illuminate/Database/Connection.php 中的 select() 方法,可以看到它还有第三个参数。

public function select($query, $bindings = [], $useReadPdo = true)
{
    return $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo) {
        if ($this->pretending()) {
            return [];
        }

        // For select statements, we'll simply execute the query and return an array
        // of the database result set. Each element in the array will be a single
        // row from the database table, and will either be an array or objects.
        $statement = $this->prepared(
            $this->getPdoForSelect($useReadPdo)->prepare($query)
        );

        $this->bindValues($statement, $this->prepareBindings($bindings));

        $statement->execute();

        return $statement->fetchAll();
    });
}

protected function getPdoForSelect($useReadPdo = true)
{
    return $useReadPdo ? $this->getReadPdo() : $this->getPdo();
}

$useReadPdo 这个参数默认就是一个 true 值,方法体内部,getPdoForSelect() 方法使用了这个参数。我们继续向下看。

public function getReadPdo()
{
    if ($this->transactions > 0) {
        return $this->getPdo();
    }

    if ($this->recordsModified && $this->getConfig('sticky')) {
        return $this->getPdo();
    }

    if ($this->readPdo instanceof Closure) {
        return $this->readPdo = call_user_func($this->readPdo);
    }

    return $this->readPdo ?: $this->getPdo();
}

// $this->readPdo laravel/framework/src/Illuminate/Database/Connectors/ConnectionFactory.php createPdoResolverWithHosts

这个方法中,其实没有做别的,最核心的就是使用 call_user_func() 去调用这个 $this->readPdo 方法,从这里可以看出这个 $this->readPdo 应该是一个回调函数。打印出来可以看到,它返回的是 laravel/framework/src/Illuminate/Database/Connectors/ConnectionFactory.php 的 createPdoResolverWithHosts() 方法所生成的一个回调函数。而其它的代码都是在判断在什么情况下直接去使用主库的 PDO 连接。那么 $this->readPdo 是在什么时候定义的呢?在当前这个文件中,我们找不到答案,Connection.php 中只有一个 setReadPdo() 方法,但没有调用设置它的代码。

public function setReadPdo($pdo)
{
    $this->readPdo = $pdo;

    return $this;
}

那么我们就向上追溯,直接去 laravel/framework/src/Illuminate/Database/Connectors/ConnectionFactory.php 连接工厂类看看,发现 createReadWriteConnection() 这个方法中调用了 setReadPdo() 方法。

public function make(array $config, $name = null)
{
    $config = $this->parseConfig($config, $name);

    if (isset($config['read'])) {
        return $this->createReadWriteConnection($config);
    }

    return $this->createSingleConnection($config);
}

protected function createReadWriteConnection(array $config)
{
    $connection = $this->createSingleConnection($this->getWriteConfig($config));

    return $connection->setReadPdo($this->createReadPdo($config));
}

protected function createReadWriteConnection(array $config)
{
    $connection = $this->createSingleConnection($this->getWriteConfig($config));

    return $connection->setReadPdo($this->createReadPdo($config));
}

protected function createReadPdo(array $config)
{
    return $this->createPdoResolver($this->getReadConfig($config));
}

protected function createPdoResolver(array $config)
{
    return array_key_exists('host', $config)
                        ? $this->createPdoResolverWithHosts($config)
                        : $this->createPdoResolverWithoutHosts($config);
}

protected function getReadConfig(array $config)
{
    return $this->mergeReadWriteConfig(
        $config, $this->getReadWriteConfig($config, 'read')
    );
}

protected function getReadWriteConfig(array $config, $type)
{
    return isset($config[$type][0])
                    ? Arr::random($config[$type])
                    : $config[$type];
}

很明显,在创建连接时,make() 方法体内根据配置文件是否有 read 配置,来调用这个 createReadWriteConnection() 方法。然后顺着我贴出的代码,可以一路看到就是如果有read 配置,那么就会先使用 write 配置创建一个主连接,接着调用这个主连接的 setReadPdo() 方法并根据 read 配置又创建了一个从数据库连接。主对象是我们的 write 连接对象,而 read 连接对象是它的一个子对象。

在 createPdoResolver() 方法中,我们看到了上面发现的那个生成回调函数的 createPdoResolverWithHosts() 方法的使用。这一下大家应该就真相大白了吧。如果还没弄清楚的同学,可以自己设置一下断点调试调试,毕竟代码位置和文件都给出了。

从这里我们可以看出,Laravel 是根据参数来判断是否使用从库连接进行查询的,而我之前看过其它框架的源码,是 Yii 还是 TP 什么来着,有根据查询语句是否有 SELECT 字符来判断走从库去查询的,也很有意思,大家可以自己去研究下哈。

语法生成

讲完连接了我们再回来讲讲数据库连接中非常重要的一个东西,那就是 SQL 语句是怎么生成的。这里使用的是 语法 这个高大上的词汇,实际上简单的理解就是 查询构造器 是如何生成 SQL 语句的。原生查询 就不用多说了,都是我们自己写 SQL 语句让 PDO 执行就好了。但是 查询构造器 以及上层的 Eloquent ORM 都是之前讲过的面向对象式的链式生成对象之后完成数据库查询的,这其中,肯定有 SQL 语句的生成过程,这就是我们接下来要学习的内容。

其实我们在 查询构造器 那篇文章中就已经看到过 Laravel 是如何生成 SQL 语句了,还记得我们分析的那个 update() 方法吗?如果不记得的小伙伴可以回去看一下 【Laravel系列4.2】查询构造器 。在执行 update() 操作时,我们最后进入了 laravel/framework/src/Illuminate/Database/Query/Grammars/Grammar.php 这个对象中。从名称就可以看出,这是一个 语法 对象。在这个对象中会负责拼接真正的 SQL 语句。比如我再来看一下 insert() 最终到达的 compileInsert() 方法。

public function compileInsert(Builder $query, array $values)
{
    // Essentially we will force every insert to be treated as a batch insert which
    // simply makes creating the SQL easier for us since we can utilize the same
    // basic routine regardless of an amount of records given to us to insert.
    $table = $this->wrapTable($query->from);

    if (empty($values)) {
        return "insert into {$table} default values";
    }

    if (! is_array(reset($values))) {
        $values = [$values];
    }

    $columns = $this->columnize(array_keys(reset($values)));

    // We need to build a list of parameter place-holders of values that are bound
    // to the query. Each insert should have the exact same amount of parameter
    // bindings so we will loop through the record and parameterize them all.
    $parameters = collect($values)->map(function ($record) {
        return '('.$this->parameterize($record).')';
    })->implode(', ');

    return "insert into $table ($columns) values $parameters";
}

最终返回的这个 SQL 语句,会交给连接,也就是 laravel/framework/src/Illuminate/Database/Connection.php 中的 insert() 方法来执行。这个就是我们最早学习使用过的那个原生查询所调用的方法。接下来,我们再看一下 get() 方法,也就是获得查询结果集的方法。在 Builder 中,get() 方法会调用一个 runSelect() 方法,这个方法里面会再调用一个 toSql() 方法,就是获得原始查询语句的方法。

public function toSql()
{
    return $this->grammar->compileSelect($this);
}

可以看到,toSql() 又到语法对象中调用了 compileSelect() 方法。

public function compileSelect(Builder $query)
{
    if ($query->unions && $query->aggregate) {
        return $this->compileUnionAggregate($query);
    }

    // If the query does not have any columns set, we'll set the columns to the
    // * character to just get all of the columns from the database. Then we
    // can build the query and concatenate all the pieces together as one.
    $original = $query->columns;

    if (is_null($query->columns)) {
        $query->columns = ['*'];
    }

    // To compile the query, we'll spin through each component of the query and
    // see if that component exists. If it does we'll just call the compiler
    // function for the component which is responsible for making the SQL.
    $sql = trim($this->concatenate(
        $this->compileComponents($query))
    );

    if ($query->unions) {
        $sql = $this->wrapUnion($sql).' '.$this->compileUnions($query);
    }

    $query->columns = $original;

    return $sql;
}

其中,基础的 SELECT 语句的拼接是在 compileComponents() 中完成的,我们继续进入这个方法。

protected function compileComponents(Builder $query)
{
    $sql = [];

    foreach ($this->selectComponents as $component) {
        if (isset($query->$component)) {
            $method = 'compile'.ucfirst($component);

            $sql[$component] = $this->$method($query, $query->$component);
        }
    }

    return $sql;
}

貌似有点看不明白呀?这一个循环是在干嘛?其实,从代码中我们可以看,它在遍历一个本地属性 selectComponents ,并根据这个属性里面的内容去调用自身的这些方法。我们查看 selectComponents 属性会发现它就是一系列方法名的预备信息。

protected $selectComponents = [
    'aggregate',
    'columns',
    'from',
    'joins',
    'wheres',
    'groups',
    'havings',
    'orders',
    'limit',
    'offset',
    'lock',
];

在循环中拼接的结果就是 compileAggregate() 、compileColumns() .... 这一系列方法,这堆方法在当前的这个语法文件中我们都可以找到。每个方法需要的额外参数是通过 $query->$component 传递进去的,这里我们也可以再回到 Builder 类中查看,在这个类中,有与 selectComponents 相对应的各个属性。在我们定义 查询构造器 的时候,这些对应的属性都会建立并赋值。这些 compile 方法执行完成之后,再通过 concatenate() 方法将 compileComponents() 中获得的那个 $sql 数组转换成一个字符串,查询的 SQL 语句就拼接完成了。

protected function concatenate($segments)
{
    return implode(' ', array_filter($segments, function ($value) {
        return (string) $value !== '';
    }));
}

你想要知道的 Where 条件、Join 语句是怎么拼接的,就全在这些 compileWheres()、compileJoins() 方法中了。这里我就不贴代码了,剩下的东西就看大家自己怎么发掘咯!

总结

今天的内容其实相对来说轻松一些,毕竟关于 Laravel 数据库方面的内容重点在于之前学习过的 模型 和 查询构造器 上。对于主从数据库来说,一般中大型的业务项目会应用得比较广泛,它实现的原理其实也并不复杂。而 语法生成 这里我们主要是看了一下查询语句的语法生成,相比增删改来说,查询语句因为存在 where/join/order by/group by 等功能,所以会更加的复杂一些。当然,更复杂的东西其实还是在构造器中,毕竟在语法生成这里其实是已经到了最后的拼装阶段了。有兴趣的同学可以多深入研究一下 Builder 对象中关于上述功能的方法实现。相信经过这一系列的学习,这个文件的内容对你已经不陌生了,也相信你已经可以自己独立的分析剩下的内容了。后面我们还要再学习两篇简单的和数据库相关的内容,分别是事务与PDO属性设置,以及 Redis 的简单使用。

参考文档:

https://learnku.com/docs/laravel/8.x/database/9400#e05dce