Forces for Good: Taylor Stitch, Part 1
VOLTAGE CEO Eric Fowles interviews the founder of menswear brand Taylor Stitch, Mike Maher, to get his take on inspiration, entrepreneurship and sustainability. Part 1 of 2.
Explore the idea of 100x faster route registration by using the route cache. How do your routes perform as your application grows?
The Laravel documentation explains that as long as you convert all your closure based routes to controller classes, you can leverage route caching. The documentation goes on to say:
Using the route cache will drastically decrease the amount of time it takes to register all of your application’s routes. In some cases, your route registration may even be up to 100x faster.
100x faster? Okay, you got my attention. How much time could route registration take anyway? It seems like it would be practically negligible, so why cache it?
In this article, we’ll review the principles behind Laravel routing, create our own routing experiments and discover when to utilize route caching to achieve speed-up.
In config/app.php
we see that App\Providers\RouteServiceProvider::class
is listed as a default framework provider. App\Providers\RouteServiceProvider::class
extends Illuminate\Foundation\Support\Providers\RouteServiceProvider
which has its own boot function that looks like this:
public function boot()
{
$this->setRootControllerNamespace();
if ($this->app->routesAreCached()) {
$this->loadCachedRoutes();
} else {
$this->loadRoutes();
$this->app->booted(function () {
$this->app['router']->getRoutes()->refreshNameLookups();
$this->app['router']->getRoutes()->refreshActionLookups();
});
}
}
(View the full file on github.)
Once the controller namespace is set, the boot method checks to see if the routes have been cached. Let’s explore what happens both with and without the route cache.
If your routes are not cached, $this->loadRoutes()
ends up calling the app/Providers/RouteServiceProvider.php
map() function which maps Api routes (mapApiRoutes
) and Web routes (mapWebRoutes
). For the sake of simplicity, comment out all routes in the routes/api.php
file so we will only be dealing with routes in the routes/web.php
file. mapWebRoutes
pulls in everything from routes/web.php
. For each entry in routes/web.php
Laravel parses the entry and converts it into a Illuminate/Routing/Route
object. This conversion requires alias resolving, determining middleware and route grouping, resolving the correct controller action and identifying the HTTP action and parameter inputs. This is done for all routes which are grouped into a final Illuminate/Routing/RouteCollection
.
Lastly, $this->app['router']->getRoutes()->refreshNameLookups()
and $this->app['router']->getRoutes()->refreshActionLookups()
are run once the app is finished booting. These are called on the Illuminate/Routing/RouteCollection
in case any new route names were generated or if any actions were overwritten by new controllers. This could be caused by other service providers later in the boot cycle.
When you run php artisan routes:cache
, an instance of Illuminate/Routing/RouteCollection
is built. After being encoded, the serialized output is written to bootstrap/cache/routes.php
.
Application requests will always load this cache file if it exists. The following is stated in the comment at the top of the file:
Here we will decode and unserialize the RouteCollection instance that holds all of the route information for an application. This allows us to instantaneously load the entire route map into the router.
In other words, our application no longer has to parse and convert entries from the routes files into Illuminate/Routing/Route
objects in a Illuminate/Routing/RouteCollection
. The application also does not call refreshNameLookups or refreshActionLookups. Be sure to always regenerate your route cache if you add/modify routes or add service providers that will add/modify your routes.
Let’s measure how much time Laravel takes to register routes by instrumenting Illuminate/Foundation/Support/Providers/RouteServiceProvider.php
. We can use PHP’s microtime()
to measure the time it takes to run blocks of code.
In Illuminate\Foundation\Support\Providers\RouteServiceProvider.php
, let’s modify the boot() function to capture time like this:
public function boot()
{
$this->setRootControllerNamespace();
// Initialize a global route load time variable
$this->app->routeLoadTime = 0;
if ($this->app->routesAreCached()) {
$time_start = microtime(true);
$this->loadCachedRoutes();
$this->app->routeLoadTime += microtime(true) - $time_start;
} else {
$time_start = microtime(true);
$this->loadRoutes();
$this->app->routeLoadTime += microtime(true) - $time_start;
$this->app->booted(function () {
$time_start = microtime(true);
$this->app['router']->getRoutes()->refreshNameLookups();
$this->app['router']->getRoutes()->refreshActionLookups();
$this->app->routeLoadTime += microtime(true) - $time_start;
});
}
$this->app->booted(function() {
dd($this->app->routeLoadTime);
});
}
If routes are not cached, we immediately measure the time of loadRoutes(). Once the app is booted, we add the time it takes to run refreshNameLookups and refreshActionLookups. Lastly, we dump out the total captured time and kill the request.
If routes are cached, we measure the time it takes to call loadCachedRoutes(). However, notice that loadCachedRoutes() defers its work until the app is booted.
protected function loadCachedRoutes()
{
$this->app->booted(function () {
require $this->app->getCachedRoutesPath();
});
}
Let’s modify loadCachedRoutes to capture that time:
protected function loadCachedRoutes()
{
$this->app->booted(function () {
$time_start = microtime(true);
require $this->app->getCachedRoutesPath();
$this->app->routeLoadTime += microtime(true) - $time_start;
});
}
Now when we use the cache we are measuring both the time to register the booted callback and the time it takes to run that callback (require $this->app->getCachedRoutesPath();
), which turns the cached routes back into a Illuminate/Routing/RouteCollection
. At the end of the app boot cycle, we dd()
the output.
Let’s measure route registration time for an application with a single route. I have a new Laravel 5.6 install running through Homestead on my local development machine. Comment out all the routes in all the routes/
files. Then go into routes/web.php
and add just one route like this:
<?php
/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/
Route::get('test', 'TestController@test');
Next add a TestController like so:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class TestController extends Controller
{
public function test() { return 'test'; }
}
Now comment out the dd()
function that we added to Illuminate\Foundation\Support\Providers\RouteServiceProvider
. If you navigate to /test
in your browser you’ll see the output test
.
If you uncomment the dd()
function and refresh the browser you’ll see a number like 0.0029568672180176
. This is the time (in seconds) we measured for the application to register the routes without using route caching.
If you comment out dd()
and run php artisan route:cache
, you’ll see that the route cache is created.
Comment out dd()
and visit /test
in your browser to see how long the same route registration took using the route cache.
In this experiment, I ran each test 10 times (with and without cache) to get better statistical averages.
Run the same experiment using 10, 100, 1,000 and 10,000 routes. routes/web.php
looks something like this:
Route::get('test0000', 'TestController@test0000');
Route::get('test0001', 'TestController@test0001');
Route::get('test0002', 'TestController@test0002');
Route::get('test0003', 'TestController@test0003');
Route::get('test0004', 'TestController@test0004');
// And so on up to test9999
And the controller looks like this:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class TestController extends Controller
{
public function test0000() { return 'test'; }
public function test0001() { return 'test'; }
public function test0002() { return 'test'; }
public function test0003() { return 'test'; }
public function test0004() { return 'test'; }
// And so on up to test9999
}
This allows us to see the effect of scaling the sheer number of routes in an application.
How does passing in arguments via the routes affect route registration? We’ll run the experiment using parameters like this:
Route::get('test0000/{arg1}/{arg2}/{arg3}/{arg4}/{arg5}', 'TestController@test0000');
Route::get('test0001/{arg1}/{arg2}/{arg3}/{arg4}/{arg5}', 'TestController@test0001');
Route::get('test0002/{arg1}/{arg2}/{arg3}/{arg4}/{arg5}', 'TestController@test0002');
Route::get('test0003/{arg1}/{arg2}/{arg3}/{arg4}/{arg5}', 'TestController@test0003');
Route::get('test0004/{arg1}/{arg2}/{arg3}/{arg4}/{arg5}', 'TestController@test0004');
// And so on
How does having multiple controllers affect route registration? We’ll use PHP to generate lots of new controllers like this. (You can run this in a route closure or in tinker.)
for ($i=0; $i<1000; $i++) {
$paddedNumber = sprintf('%03d', $i);
$file = fopen("../app/Http/Controllers/TestController$paddedNumber.php", "w");
$txt = <<<EOD
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class TestController$paddedNumber extends Controller
{
public function test() {
return 'test';
}
}
EOD;
fwrite($file, $txt);
fclose($file);
}
This spits out 1000 controllers named TestController000.php
through TestController999.php
that we can route to like so:
Route::get('test0000', 'TestController0000@test');
Route::get('test0001', 'TestController0001@test');
Route::get('test0002', 'TestController0002@test');
Route::get('test0003', 'TestController0003@test');
Route::get('test0004', 'TestController0004@test');
// And so on
The following are the average results for route registration given different # of routes.
No cache (ms) | Cache (ms) | Speed-up | |
---|---|---|---|
1 route | 2.5 | 1.9 | 1.3x |
10 | 5.2 | 2.6 | 2.0x |
100 | 22.5 | 4.3 | 5.3x |
1,000 | 166 | 32 | 5.1x |
10,000 | 1,513 | 334 | 4.5x |
By putting both the number of requests and the route registration duration on logarithmic scales we see an almost linear increase in route registration time as we add more routes to the application. This makes sense since Laravel loops through and processes each route entry one at a time.
Speed-up is calculated by (old time) / (new time). Looking only at route registration speed-up resulting from route caching, we get the following (note that the # of routes is displayed on a logarithmic scale):
These exact results are affected by the physical limitations of my development machine. However, we can still make some intelligent conclusions about route caching.
When we only had 10 routes, route caching would only shave 5.2 ms of route registration down to 2.6 ms. True, this is a speed-up of 2x, but it still only improves our total request lifecycle by 2.6 ms.
If our application has at least 100 routes, we start seeing speed-up around 5x. At this point we should definitely have route caching enabled. The route cache saves almost 20 ms per request and saves more as our application grows.
Here are the results for testing the effect of having route arguments and the effect of having a separate controller file for each route given an application with 1,000 routes.
No cache (ms) | Cache (ms) | Speed-up | |
---|---|---|---|
Baseline | 166 | 32 | 5.1x |
5 arguments | 186 | 41 | 4.5x |
Separate controllers | 165 | 29 | 5.7x |
Surprisingly, adding lots of arguments to routes only added around 10% extra time to our route registration without caching, though it added almost 30% when using caching. This is likely due to the larger quantity of information that the route cache is storing which needs to be loaded back into memory when the application runs.
Another interesting discovery is that using 1,000 controllers with one function each performed almost exactly the same in the route registration cycle as using one controller with all 1,000 functions, both cached and non-cached.
We certainly weren’t able to accomplish route registration 100x faster as mentioned in the Laravel documentation, but we achieved 5x which is significant enough to make a noticeable performance improvement in larger applications. Perhaps if we had registered routes in all 4 routes files we could have achieved higher total speed-up. Let me know if you discover a scenario where speed-up approaches 100x.
VOLTAGE is a digital agency specializing in eCommerce, digital brand experiences, and web apps. Get emails and insights from our team:
VOLTAGE CEO Eric Fowles interviews the founder of menswear brand Taylor Stitch, Mike Maher, to get his take on inspiration, entrepreneurship and sustainability. Part 1 of 2.
VOLTAGE’s Eric Fowles interviews the founder of menswear brand Taylor Stitch, Mike Maher, to get his take on inspiration, entrepreneurship, and sustainability and the state of the fashion and apparel industry. Part 2 of 2.