クエリビルダを独自のものに差し替えてパフォーマンス改善する方法


はじめに

Laravelはレールがない分、使い方によってパフォーマンスが良くも悪くもなります。
その分、拡張性に優れ、機能が豊富であるため、大人数・大規模開発にも優れています。

今回の記事では、Laravelのパフォーマンスを上げる上で重要となってくるクエリビルダの差し替えについて記事にしたいと思います。

動作確認環境

  • PHP 8.0
  • Laravel 8.0

※下位環境でも動作する場合がございます

クエリビルダとは

DB::table('samples')->get();

↓ こういう形になって返ってくる

Illuminate\Support\Collection^ {#836
  #items: array:2 [
    0 => {#838
      +"id": 1
      +"name": 11
    }
    1 => {#840
      +"id": 2
      +"name": 15
    }
  ]
}

Laravelにはクエリを発行するための手法としてeloquent(EloquentBuilder)とQueryBuilderという2種類が存在しています。
PDOにより近しいのがQueryBuilderで、パフォーマンス的にもeloquentの無駄な処理が走らないため高速です。

しかし、このQueryBuilderですがLaravel側が勝手に気を利かせてcollection化してくれるうえ、Laravel5.4からはStdClassを返すこと固定となってしまいました。
collection化はパフォーマンス劣化になるので勝手にしてほしくないですし、StdClassでは扱いづらいので、連想配列が返るように差し替えを試みたいと思います。
なお、パフォーマンスの面でもstdClassよりも連想配列の方が優れているようです。

公式ドキュメントとしてはIlluminate\Database\Events\StatementPreparedイベントをリッスンする手法の提案がなされていますが、クエリー発行の度に差し替えするのはパフォーマンスに影響が出そうですし、何よりもLaravel標準のQueryBuilderは他にも足りない機能があるので、クラスごと差し替えしたいと思います。

前提知識

DB::table('samples')

と記述すると

Illuminate/Database/Connection.phpにある

/**
 * Begin a fluent query against a database table.
 *
 * @param  \Closure|\Illuminate\Database\Query\Builder|string  $table
 * @param  string|null  $as
 * @return \Illuminate\Database\Query\Builder
 */
public function table($table, $as = null)
{
    return $this->query()->from($table, $as);
}

こちらが呼ばれ、\Illuminate\Database\Query\Builderが返るようになっています。
今回はこのQueryBuilder(->query()の部分)を自前のものに差し替えるのが目的です。

仕組みの理解

DB::table('samples')

差し替えを行うにあたって、↑を実行した際、内部ではどういったことが行われているのかを見ていきたいと思います。

DB::

まず、これはLaravelのFacadeです。

app.php
'DB'           => Illuminate\Support\Facades\DB::class,

Illuminate\Support\Facades\DB.php
class DB extends Facade
{
    /**
     * Get the registered name of the component.
     *
     * @return string
     */
    protected static function getFacadeAccessor()
    {
        return 'db';
    }
}

Illuminate\Support\Facades\DB.php
... 

/**
 * Register the primary database bindings.
 *
 * @return void
 */
protected function registerConnectionServices()
{
    // The connection factory is used to create the actual connection instances on
    // the database. We will inject the factory into the manager so that it may
    // make the connections while they are actually needed and not of before.
    $this->app->singleton('db.factory', function ($app) {
        return new ConnectionFactory($app);
    });

    // The database manager is used to resolve various connections, since multiple
    // connections might be managed. It also implements the connection resolver
    // interface which may be used by other components requiring connections.
    $this->app->singleton('db', function ($app) {
        return new DatabaseManager($app, $app['db.factory']);
    });

    $this->app->bind('db.connection', function ($app) {
        return $app['db']->connection();
    });

    $this->app->singleton('db.transactions', function ($app) {
        return new DatabaseTransactionsManager;
    });
}

たどりつきました。DBの実態はIlluminate/Database\DatabaseManager.phpであることがうかがえます。

DB::table('sample')

DatabaseManager.phpの中にtableメソッドがあるはず...がありません。
Laravelではよくあることですが、定義されていないので、マジックメソッドであることがうかがえますので__call()を確認します。

Illuminate/Database\DatabaseManager.php
/**
 * Dynamically pass methods to the default connection.
 *
 * @param  string  $method
 * @param  array  $parameters
 * @return mixed
 */
public function __call($method, $parameters)
{
    return $this->connection()->$method(...$parameters);
}

Illuminate/Database\DatabaseManager.php
/**
 * Get a database connection instance.
 *
 * @param  string|null  $name
 * @return \Illuminate\Database\Connection
 */
public function connection($name = null)
{
    [$database, $type] = $this->parseConnectionName($name);

    $name = $name ?: $database;

    // If we haven't created this connection, we'll create it based on the config
    // provided in the application. Once we've created the connections we will
    // set the "fetch mode" for PDO which determines the query return types.
    if (! isset($this->connections[$name])) {
        $this->connections[$name] = $this->configure(
            $this->makeConnection($database), $type
        );
    }

    return $this->connections[$name];
}

↓ makeConnection($database)で作られてることがうかがえます。

Illuminate/Database\DatabaseManager.php
/**
 * Make the database connection instance.
 *
 * @param  string  $name
 * @return \Illuminate\Database\Connection
 */
protected function makeConnection($name)
{
    $config = $this->configuration($name);

    // First we will check by the connection name to see if an extension has been
    // registered specifically for that connection. If it has we will call the
    // Closure and pass it the config allowing it to resolve the connection.
    if (isset($this->extensions[$name])) {
        return call_user_func($this->extensions[$name], $config, $name);
    }

    // Next we will check to see if an extension has been registered for a driver
    // and will call the Closure if so, which allows us to have a more generic
    // resolver for the drivers themselves which applies to all connections.
    if (isset($this->extensions[$driver = $config['driver']])) {
        return call_user_func($this->extensions[$driver], $config, $name);
    }

    return $this->factory->make($config, $name);
}

やっと到達できました。

return $this->connection()->$method(...$parameters);

は言い換えると

return $this->factory->make($config, $name)->$method(...$parameters);

に言い換えられます。

そして、この$this->factoryはconstructで外から注入されていることがうかがえます。

Illuminate/Database/DatabaseManager.php
/**
 * Create a new database manager instance.
 *
 * @param  \Illuminate\Contracts\Foundation\Application  $app
 * @param  \Illuminate\Database\Connectors\ConnectionFactory  $factory
 * @return void
 */
public function __construct($app, ConnectionFactory $factory)
{
    $this->app = $app;
    $this->factory = $factory;

    $this->reconnector = function ($connection) {
        $this->reconnect($connection->getName());
    };
}

ということは、話は前に戻ります。

Illuminate\Support\Facades\DB.php
... 

/**
 * Register the primary database bindings.
 *
 * @return void
 */
protected function registerConnectionServices()
{
    // The connection factory is used to create the actual connection instances on
    // the database. We will inject the factory into the manager so that it may
    // make the connections while they are actually needed and not of before.
    $this->app->singleton('db.factory', function ($app) {
        return new ConnectionFactory($app); ← これ
    });

    // The database manager is used to resolve various connections, since multiple
    // connections might be managed. It also implements the connection resolver
    // interface which may be used by other components requiring connections.
    $this->app->singleton('db', function ($app) {
        return new DatabaseManager($app, $app['db.factory']); ← これ
    });
}

ありました。
$app['db.factory']
がその実体であるといえます。
さらに、
$app['db.factory']
は上で注入されているので、ConnectionFactory($app);が実体であるといえます。

ということは、

return $this->factory->make($config, $name)->$method(...$parameters);

これは

return new ConnectionFactory($app)->make($config, $name)->$method(...$parameters);

であることが分かりました。ということで、次にmakeを探します。

Illuminate/Database/Connectors/ConnectionFactory.php
/**
 * Establish a PDO connection based on the configuration.
 *
 * @param  array  $config
 * @param  string|null  $name
 * @return \Illuminate\Database\Connection
 */
public function make(array $config, $name = null)
{
    $config = $this->parseConfig($config, $name);

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

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



/**
 * Create a single database connection instance.
 *
 * @param  array  $config
 * @return \Illuminate\Database\Connection
 */
protected function createSingleConnection(array $config)
{
    $pdo = $this->createPdoResolver($config);

    return $this->createConnection(
        $config['driver'], $pdo, $config['database'], $config['prefix'], $config
    );
}



/**
 * Create a new connection instance.
 *
 * @param  string  $driver
 * @param  \PDO|\Closure  $connection
 * @param  string  $database
 * @param  string  $prefix
 * @param  array  $config
 * @return \Illuminate\Database\Connection
 *
 * @throws \InvalidArgumentException
 */
protected function createConnection($driver, $connection, $database, $prefix = '', array $config = [])
{
    if ($resolver = Connection::getResolver($driver)) {
        return $resolver($connection, $database, $prefix, $config);
    }

    switch ($driver) {
        case 'mysql':
            return new MySqlConnection($connection, $database, $prefix, $config);
        case 'pgsql':
            return new PostgresConnection($connection, $database, $prefix, $config);
        case 'sqlite':
            return new SQLiteConnection($connection, $database, $prefix, $config);
        case 'sqlsrv':
            return new SqlServerConnection($connection, $database, $prefix, $config);
    }

    throw new InvalidArgumentException("Unsupported driver [{$driver}].");
}

 mysqlの場合は...

class MySqlConnection extends Connection ← 継承元にtable()メソッドがある
{
....
}

クエリビルダの差し替え

Laravelはサービスプロバイダで既存の機能を差し替えすることができるので、「DB::」という記述をした際に使われるクラスなどを差し替えします。

/**
 * Class DatabaseServiceProvider
 */
class DatabaseServiceProvider extends ServiceProvider
{
    /**
     * Register services.
     */
    public function register()
    {
        $this->registerConnectionServices();
    }

    /**
     * Register the primary database bindings.
     */
    protected function registerConnectionServices(): void
    {
        // The connection factory is used to create the actual connection instances on
        // the database. We will inject the factory into the manager so that it may
        // make the connections while they are actually needed and not of before.
        $this->app->singleton('db.factory', function ($app) {
            return new ConnectionFactory($app);   自分で作ったクラスに差し替えします
        });

        // The database manager is used to resolve various connections, since multiple
        // connections might be managed. It also implements the connection resolver
        // interface which may be used by other components requiring connections.
        $this->app->singleton('db', function ($app) {
            return new DatabaseManager($app, $app['db.factory']);   自分で作ったクラスに差し替えします
        });

        $this->app->bind('db.connection', function ($app) {
            return $app['db']->connection();
        });
    }
}

---
use Illuminate\Database\Connectors\ConnectionFactory as BaseConnectionFactory;

/**
 * Class ConnectionFactory
 */
class ConnectionFactory extends BaseConnectionFactory
{
    /**
     * Create a new connection instance.
     *
     * @param string $driver
     * @param \PDO|\Closure $connection
     * @param string $database
     * @param string $prefix
     * @param array $config
     * @return \Illuminate\Database\Connection
     * @throws \InvalidArgumentException
     */
    protected function createConnection($driver, $connection, $database, $prefix = '', array $config = [])
    {
        if ($driver === 'mysql') {
            return new MySqlConnection($connection, $database, $prefix, $config);
        }

        return parent::createConnection($driver, $connection, $database, $prefix = '', $config);
    }
}

---
use Illuminate\Database\DatabaseManager as BaseDatabaseManager;

/**
 * Class DatabaseManager
 */
class DatabaseManager extends BaseDatabaseManager
{
    // 今後のためにもこちらも自前のものに差し替えしておく
}

---
use PDO;
use Illuminate\Database\MySqlConnection as BaseConnection;

/**
 * Class MySqlConnection
 */
class MySqlConnection extends BaseConnection
{
    /**
     * The default fetch mode of the connection.
     *
     * @var int
     */
    protected $fetchMode = PDO::FETCH_ASSOC;   PDOのタイプを変更する

    /**
     * Get a new query builder instance.
     */
    public function query(): QueryBuilder
    {
        return new QueryBuilder($this, $this->getQueryGrammar(), $this->getPostProcessor()); ← 自前のQueryBuilderに差し替え
    }
}

---
use Illuminate\Database\Query\Builder as BaseBuilder;

class QueryBuilder extends BaseBuilder
{
    /**
     * Execute the query as a "select" statement.
     *
     * @param  array|string  $columns
     * @return array
     */
    public function get($columns = ['*']): array
    {
        return $this->onceWithColumns(Arr::wrap($columns), function () {
            return $this->processor->processSelect($this, $this->runSelect());
        });
    }
}

これですべて差し替え完了です。
※差し替えに使ったDatabaseServiceProviderはapp.phpに登録が必要です

まとめ

Illuminate\Support\Collection^ {#836
  #items: array:2 [
    0 => {#838
      +"id": 1
      +"name": 11
    }
    1 => {#840
      +"id": 2
      +"name": 15
    }
  ]
}

↓ すべて差し替えが完了すると...。

array:2 [
  0 => array:2 [
    "id" => 1
    "name" => 11
  ]
  1 => array:2 [
    "id" => 2
    "name" => 15
  ]
]

こうなります!

おまけ

Laravelが重いと言われる所以はいくつかあると思いますが、Eloquentが重いというのが理由の一つだと思います。
プロジェクト規模や工期などにもよると思いますが、パフォーマンスを求める必要がある場合にはEloquent中心の開発を捨ててQueryBuilder中心とした開発がベターだと思います。

謝辞