说明:Laravel在把Request通过管道Pipeline
送入中间件Middleware和路由Router之前,还做了程序的启动Bootstrap工作,本文主要学习相关源码,看看Laravel启动程序做了哪些具体工作,并将个人的研究心得分享出来,希望对别人有所帮助。Laravel在入口index.php时先加载Composer加载器:Laravel学习笔记之Composer自动加载,然后进行Application的实例化:Laravel学习笔记之IoC Container实例化源码解析,得到实例化后的Application对象再从容器中解析出Kernel服务,然后进行Request实例化(Request实例化下次再聊),然后进行Bootstrap操作启动程序
,再通过Pipeline送到Middleware:Laravel学习笔记之Middleware源码解析,然后经过路由映射找到对该请求的操作action(以后再聊),生成Response对象经过Kernel的send()发送给Client。本文主要聊下程序的启动操作,主要做了哪些准备工作。
开发环境:Laravel5.3 + PHP7 + OS X 10.11
在Laravel学习笔记之Middleware源码解析聊过,Kernel中的sendRequestThroughRouter()处理Request,并把Request交给Pipeline送到Middleware和Router中,看源码:
protected function sendRequestThroughRouter($request)
{
$this->app->instance('request', $request);
Facade::clearResolvedInstance('request');
/* 依次执行$bootstrappers中每一个bootstrapper的bootstrap()函数,做了几件准备事情:
1. 环境检测 DetectEnvironment
2. 配置加载 LoadConfiguration
3. 日志配置 ConfigureLogging
4. 异常处理 HandleException
5. 注册Facades RegisterFacades
6. 注册Providers RegisterProviders
7. 启动Providers BootProviders
protected $bootstrappers = [
'Illuminate\Foundation\Bootstrap\DetectEnvironment',
'Illuminate\Foundation\Bootstrap\LoadConfiguration',
'Illuminate\Foundation\Bootstrap\ConfigureLogging',
'Illuminate\Foundation\Bootstrap\HandleExceptions',
'Illuminate\Foundation\Bootstrap\RegisterFacades',
'Illuminate\Foundation\Bootstrap\RegisterProviders',
'Illuminate\Foundation\Bootstrap\BootProviders',
];*/
$this->bootstrap();
return (new Pipeline($this->app))
->send($request)
->through($this->app->shouldSkipMiddleware() ? [] : $this->middleware)
->then($this->dispatchToRouter());
}
在Request被Pipeline送到Middleware前还有一步操作bootstrap()操作,这步操作就是启动程序
,看下\Illuminate\Foundation\Http\Kernel中的bootstrap()源码:
protected $hasBeenBootstrapped = false;
...
/**
* Bootstrap the application for HTTP requests.
*
* @return void
*/
public function bootstrap()
{
// 检查程序是否已经启动
if (! $this->app->hasBeenBootstrapped()) {
$this->app->bootstrapWith($this->bootstrappers());
}
}
public function hasBeenBootstrapped()
{
return $this->hasBeenBootstrapped;
}
protected function bootstrappers()
{
return $this->bootstrappers;
}
protected $bootstrappers = [
'Illuminate\Foundation\Bootstrap\DetectEnvironment',
'Illuminate\Foundation\Bootstrap\LoadConfiguration',
'Illuminate\Foundation\Bootstrap\ConfigureLogging',
'Illuminate\Foundation\Bootstrap\HandleExceptions',
'Illuminate\Foundation\Bootstrap\RegisterFacades',
'Illuminate\Foundation\Bootstrap\RegisterProviders',
'Illuminate\Foundation\Bootstrap\BootProviders',
];
从以上源码可知道,程序将会依次bootstrapWith()数组$bootstrappers中各个bootstrapper,看下容器中的bootstrapWith()源码:
public function bootstrapWith(array $bootstrappers)
{
$this->hasBeenBootstrapped = true;
foreach ($bootstrappers as $bootstrapper) {
$this['events']->fire('bootstrapping: '.$bootstrapper, [$this]);
$this->make($bootstrapper)->bootstrap($this);
$this['events']->fire('bootstrapped: '.$bootstrapper, [$this]);
}
}
首先触发'bootstrapping: '.$bootstrapper事件,告知将要启动该bootstrapper,然后从容器中make($bootstrapper)出该$bootstrapper,并执行该$bootstrapper中的bootstrap()方法,最后在触发事件:'bootstrapped: '.$bootstrapper,告知该$bootstrapper已经启动OK了。启动的bootstrappers就是数组$bootstrappers中的7个bootstrapper,看下程序做了哪些启动工作。
1. 环境检测
查看Illuminate\Foundation\Bootstrap\DetectEnvironment中的bootstrap()源码:
public function bootstrap(Application $app)
{
// 查看bootstrap/cache/config.php缓存文件是否存在
// php artisan config:cache来生成配置缓存文件,就是把config/下的所有文件放在一个缓存文件内,提高性能
// 这里假设没有缓存配置文件
if (! $app->configurationIsCached()) {
$this->checkForSpecificEnvironmentFile($app);
try {
$env = $_ENV; // 调试添加的,此时为空
// 这里把.env文件值取出存入$_ENV内
(new Dotenv($app->environmentPath(), $app->environmentFile()))->load();
// 这里$_ENV数组有值了
$env = $_ENV;
} catch (InvalidPathException $e) {
//
}
}
}
protected function checkForSpecificEnvironmentFile($app)
{
// 读取$_ENV全局变量中'APP_ENV'值,此时是空
if (! env('APP_ENV')) {
return;
}
$file = $app->environmentFile().'.'.env('APP_ENV'); // .env.local
if (file_exists($app->environmentPath().'/'.$file)) {
$app->loadEnvironmentFrom($file);
}
}
环境监测核心就是把.env文件内值存入到$_ENV全局变量中\Dotenv\Dotenv::load()函数实现了这个功能,具体不详述了。可以通过Xdebug调试查看:
2. 配置加载
配置加载就是读取config/文件夹下的所有配置值,然后存入\Illuminate\Config\Repository对象中,而环境检测是读取.env文件存入$_ENV全局变量中,加载环境配置主要是使用\Symfony\Component\Finder\Finder这个组件进行文件查找,看下LoadConfiguration::bootstrap()的源码:
public function bootstrap(Application $app)
{
$items = [];
// 查看config有没有缓存文件,缓存文件是在bootstrap/cache/config.php
// 通过php artisan config:cache命令来生成缓存文件,把config/下的所有配置文件打包成一个文件,提高程序执行速度
// 这里假设没有缓存文件
if (file_exists($cached = $app->getCachedConfigPath())) {
$items = require $cached;
$loadedFromCache = true;
}
// 绑定服务'config',服务是\Illuminate\Config\Repository对象
$app->instance('config', $config = new Repository($items));
if (! isset($loadedFromCache)) {
// 加载config/*.php所有配置文件,把所有配置存入Repository对象中
$this->loadConfigurationFiles($app, $config);
}
// 检查'APP_ENV'环境设置,一般也就是'dev','stg','prd'三个环境,即'development', 'staging', 'production'
$app->detectEnvironment(function () use ($config) {
return $config->get('app.env', 'production');
});
// 设置时区,$config['app.timezone']就是调用Repository::get('app.timezone'),因为Repository实现了ArrayAccess Interface,
// '.'语法读取是Arr::get()实现的,很好用的一个方法
date_default_timezone_set($config['app.timezone']);
mb_internal_encoding('UTF-8');
}
加载配置文件,就是读取/config/*.php文件,看下源码:
protected function loadConfigurationFiles(Application $app, RepositoryContract $repository)
{
foreach ($this->getConfigurationFiles($app) as $key => $path) {
// 存入到Repository对象中,以'key => value'存入到$items[]属性中
$repository->set($key, require $path);
}
}
protected function getConfigurationFiles(Application $app)
{
$files = [];
// 就是'config/'这个路径
$configPath = realpath($app->configPath());
// Finder链式接口读取config/*.php所有文件,获取所有文件名称,然后依次遍历
foreach (Finder::create()->files()->name('*.php')->in($configPath) as $file) {
$nesting = $this->getConfigurationNesting($file, $configPath);
$files[$nesting.basename($file->getRealPath(), '.php')] = $file->getRealPath();
}
return $files;
}
可以通过Xdebug调试知道$files的返回值是这样的数组:
$files = [
'app' => '/vagrant/config/app.php', //文件的绝对路径
'auth' => 'vagrant/config/auth.php',
'broadcasting' => '/vagrant/config/broadcasting.php',
'cache' => '/vagrant/config/cache.php',
'compile' => 'vagrant/config/compile.php',
'database' => '/vagrant/config/databse.php',
'filesystems' => '/vagrant/config/filesystems.php',
'mail' => '/vagrant/config/mail.php',
'queue' => '/vagrant/config/queue.php',
'services' => '/vagrant/config/services.php',
'session' => '/vagrant/config/session.php',
'view' => '/vagrant/config/view.php',
];
然后通过Application的detectEnvironment()方法把app.env
的值即app.php
中env
的值取出来存入Application对象的$env属性中:
public function detectEnvironment(Closure $callback)
{
$args = isset($_SERVER['argv']) ? $_SERVER['argv'] : null;
return $this['env'] = (new EnvironmentDetector())->detect($callback, $args);
}
public function detect(Closure $callback, $consoleArgs = null)
{
if ($consoleArgs) {
return $this->detectConsoleEnvironment($callback, $consoleArgs);
}
return $this->detectWebEnvironment($callback);
}
protected function detectWebEnvironment(Closure $callback)
{
return call_user_func($callback);
}
所以属性检查的时候就存到了$env属性的值了,开发代码中就可以App::environment()得到这个$env属性然后进行一些操作,可以看下environment()的源码,该方法有两个feature:如果不传入值则读取$env值;如果传入值则判断该值是否与$env一样。这里如果对Application没有$env成员属性定义有疑惑,是因为PHP可以后期添加属性,如:
class ClassField
{
}
$class_field = new ClassField();
$class_field->name = 'Laravel';
echo $class_field->name . PHP_EOL;
/* output:
Laravel
3. 日志配置
Laravel主要利用Monolog日志库来做日志处理,\Illuminate\Log\Writer相当于Monolog Bridge,把Monolog库接入到Laravel中。看下ConfigureLogging::bootstrap()源码:
public function bootstrap(Application $app)
{
// 注册'log'服务
$log = $this->registerLogger($app);
// 检查是否已经注册了Monolog
// 这里假设开始没有注册
if ($app->hasMonologConfigurator()) {
call_user_func(
$app->getMonologConfigurator(), $log->getMonolog()
);
} else {
//
$this->configureHandlers($app, $log);
}
}
protected function registerLogger(Application $app)
{
// 向容器中绑定'log'服务,即Writer对象
$app->instance('log', $log = new Writer(
new Monolog($app->environment()), $app['events'])
);
return $log;
}
Laravel的Log模块中已经内置了几个类型的LogHandler:Single,Daily,Syslog,Errorlog.根据config/app.php文件中'log'的配置选择其中一个handler,看下configureHandlers()源码:
protected function configureHandlers(Application $app, Writer $log)
{
$method = 'configure'.ucfirst($app['config']['app.log']).'Handler';
$this->{$method}($app, $log);
}
configureHandlers()这方法也是一个技巧,找到方法名然后调用,这在Laravel中经常这么用,如Filesystem那一模块中有'create'.ucfirst(xxx).'Driver'这样的源码,是个不错的设计。这里看下configureDailyHandler()的源码,其余三个也类似:
protected function configureDailyHandler(Application $app, Writer $log)
{
// 解析'config'服务
$config = $app->make('config');
// 默认没有设置,就为null
$maxFiles = $config->get('app.log_max_files');
$log->useDailyFiles(
$app->storagePath().'/logs/laravel.log', // storage/log/laravel.log
is_null($maxFiles) ? 5 : $maxFiles, // 5
$config->get('app.log_level', 'debug')
);
}
// Writer.php
public function useDailyFiles($path, $days = 0, $level = 'debug')
{
$this->monolog->pushHandler(
$handler = new RotatingFileHandler($path, $days, $this->parseLevel($level))
);
$handler->setFormatter($this->getDefaultFormatter());
}
利用Mnolog的RotatingFileHandler()来往laravel.log里打印log值,当然在应用程序中经常\Log::info(),\Log::warning(),\Log::debug()来打印变量值,即Writer类中定义的的方法。Log的facade是\Illuminate\Support\Facades\Log:
class Log extends Facade
{
/**
* Get the registered name of the component.
*
* @return string
*/
protected static function getFacadeAccessor()
{
return 'log';
}
}
而'log'服务在上文中bootstrap()源码第一步registerLogger()就注册了。当然,至于使用Facade来从容器中获取服务也聊过,也不复杂,看下\Illuminate\Support\Facades\Facade的resolveFacadeInstance()源码就知道了:
protected static function resolveFacadeInstance($name)
{
if (is_object($name)) {
return $name;
}
if (isset(static::$resolvedInstance[$name])) {
return static::$resolvedInstance[$name];
}
return static::$resolvedInstance[$name] = static::$app[$name]; // 实际上就是使用$app['log']来获取服务
}
4. 异常处理
异常处理是十分重要的,Laravel中异常处理类\App\Exception\Handler中有一个方法report(),该方法可以用来向第三方服务(如Sentry)发送程序异常堆栈(以后在一起聊聊这个Sentry,效率神器),如Production Code线上环境报出个异常,可以很清楚整个堆栈,出错在哪一行:
OK,看下异常设置的启动源代码,HandleExceptions::bootstrap()的源码:
public function bootstrap(Application $app)
{
$this->app = $app;
error_reporting(-1);
// 出现错误,抛出throw new ErrorException
set_error_handler([$this, 'handleError']);
// 处理异常,使用report()方法来报告,可集成第三方服务Sentry来作为异常报告处理器ExceptionReportHandler
set_exception_handler([$this, 'handleException']);
register_shutdown_function([$this, 'handleShutdown']);
if (! $app->environment('testing')) {
ini_set('display_errors', 'Off');
}
}
这里重点看下handleException()的源码:
public function handleException($e)
{
if (! $e instanceof Exception) {
$e = new FatalThrowableError($e);
}
// (new App\Exceptions\Handler($container))->report($e)
$this->getExceptionHandler()->report($e);
if ($this->app->runningInConsole()) {
$this->renderForConsole($e);
} else {
$this->renderHttpResponse($e);
}
}
protected function getExceptionHandler()
{
// 解析出App\Exceptions\Handler对象
// 在boostrap/app.php中做过singleton()绑定
return $this->app->make('Illuminate\Contracts\Debug\ExceptionHandler');
}
protected function renderHttpResponse(Exception $e)
{
// 使用(new App\Exceptions\Handler($container))->render(Request $request, $e)
$this->getExceptionHandler()->render($this->app['request'], $e)->send();
}
从源码中知道,重点是使用App\Exceptions\Handler的report()方法报告异常情况,如向Sentry报告异常堆栈和其他有用信息;App\Exceptions\Handler的render()方法通过Request发送到浏览器。关于使用第三方服务Sentry来做异常报告以后详聊,我司每天都在用这样的效率神器,很好用,值得推荐下。
5. 注册Facades
在路由文件中经常会出现Route::get()这样的写法,但实际上并没有Route类,Route只是\Illuminate\Support\Facades\Route::class外观类的别名,这样取个别名只是为了简化作用,使用的是PHP内置函数class_alias(string $class, string $alias)来给类设置别名。看下RegisterFacades::bootstrap()的源码:
public function bootstrap(Application $app)
{
Facade::clearResolvedInstances();
Facade::setFacadeApplication($app);
AliasLoader::getInstance($app->make('config')->get('app.aliases', []))->register();
}
// \Illuminate\Support\Facades\Facade
public static function clearResolvedInstances()
{
static::$resolvedInstance = [];
}
// \Illuminate\Support\Facades\Facade
public static function setFacadeApplication($app)
{
static::$app = $app;
}
$app->make('config')->get('app.aliases', [])
是从config/app.php中读取'aliases'的值,然后注册外观类的别名,注册的外观类有:
'aliases' => [
'App' => Illuminate\Support\Facades\App::class,
'Artisan' => Illuminate\Support\Facades\Artisan::class,
'Auth' => Illuminate\Support\Facades\Auth::class,
'Blade' => Illuminate\Support\Facades\Blade::class,
'Cache' => Illuminate\Support\Facades\Cache::class,
'Config' => Illuminate\Support\Facades\Config::class,
'Cookie' => Illuminate\Support\Facades\Cookie::class,
'Crypt' => Illuminate\Support\Facades\Crypt::class,
'DB' => Illuminate\Support\Facades\DB::class,
'Eloquent' => Illuminate\Database\Eloquent\Model::class,
'Event' => Illuminate\Support\Facades\Event::class,
'File' => Illuminate\Support\Facades\File::class,
'Gate' => Illuminate\Support\Facades\Gate::class,
'Hash' => Illuminate\Support\Facades\Hash::class,
'Lang' => Illuminate\Support\Facades\Lang::class,
'Log' => Illuminate\Support\Facades\Log::class,
'Mail' => Illuminate\Support\Facades\Mail::class,
'Notification' => Illuminate\Support\Facades\Notification::class,
'Password' => Illuminate\Support\Facades\Password::class,
'Queue' => Illuminate\Support\Facades\Queue::class,
'Redirect' => Illuminate\Support\Facades\Redirect::class,
'Redis' => Illuminate\Support\Facades\Redis::class,
'Request' => Illuminate\Support\Facades\Request::class,
'Response' => Illuminate\Support\Facades\Response::class,
'Route' => Illuminate\Support\Facades\Route::class,
'Schema' => Illuminate\Support\Facades\Schema::class,
'Session' => Illuminate\Support\Facades\Session::class,
'Storage' => Illuminate\Support\Facades\Storage::class,
'URL' => Illuminate\Support\Facades\URL::class,
'Validator' => Illuminate\Support\Facades\Validator::class,
'View' => Illuminate\Support\Facades\View::class,
],
从以上外观别名数组中知道Route
是IlluminateSupportFacadesRoute::class
的别名,所以Route::get()
实际上就是IlluminateSupportFacadesRoute::get()
,看下AliasLoader类的getInstance()和register()方法源码:
public static function getInstance(array $aliases = [])
{
if (is_null(static::$instance)) {
// 这里$aliases就是上面传进来的$aliases[],即config/app.php中'aliases'值
return static::$instance = new static($aliases);
}
$aliases = array_merge(static::$instance->getAliases(), $aliases);
static::$instance->setAliases($aliases);
return static::$instance;
}
public function register()
{
if (! $this->registered) {
$this->prependToLoaderStack();
$this->registered = true;
}
}
protected function prependToLoaderStack()
{
// 把AliasLoader::load()放入自动加载函数堆栈中,堆栈首的位置
spl_autoload_register([$this, 'load'], true, true);
}
而loader()函数的源码:
public function load($alias)
{
if (isset($this->aliases[$alias])) {
// @link http://php.net/manual/en/function.class-alias.php
return class_alias($this->aliases[$alias], $alias);
}
}
就是通过class_alias()给外观类设置一个别名。所以Route::get()
的调用过程就是,首先发现没有Route
类,就去自动加载函数堆栈中通过AliasLoader::load()
函数查找到Route
是IlluminateSupportFacadesRoute
的别名,那就调用IlluminateSupportFacadesRoute::get()
,当然这里IlluminateSupportFacadesRoute
没有get()
静态方法,那就调用父类Facade
的__callStatic()
来找到名为router
的服务,名为'router'的服务那就是早就注册到容器中的IlluminateRoutingRouter
对象,所以最终就是调用IlluminateRoutingRouter::get()
方法。这个过程主要使用了两个技术:一个是外观类的别名;一个是PHP的重载,可看这篇:Laravel学习笔记之PHP重载(overloading)。
6. 注册Providers
外观注册是注册config/app.php中的$aliases[ ]
得值,Providers注册就是注册$providers[ ]
的值。看下RegisterProviders::bootstrap()的源码:
public function bootstrap(Application $app)
{
$app->registerConfiguredProviders();
}
// Application.php
public function registerConfiguredProviders()
{
// 查找bootstrap/cache/services.php有没有这个缓存文件
// services.php这个缓存文件存储的是service providers的数组值:
// return [
// 'providers' => [],
// 'eager' => [],
// 'deferred' => [],
// 'when' => []
// ];
$manifestPath = $this->getCachedServicesPath();
// 通过load()方法加载config/app.php中'$providers[ ]'数组值
(new ProviderRepository($this, new Filesystem, $manifestPath))
->load($this->config['app.providers']);
}
看下load()的源码:
public function load(array $providers)
{
// 查看bootstrap/cache/services.php有没有这个缓存文件
// 第一次启动时是没有的
$manifest = $this->loadManifest();
// 开始没有这个缓存文件,那就把$providers[ ]里的值
if ($this->shouldRecompile($manifest, $providers)) {
// 然后根据$providers[ ]编译出services.php这个缓存文件
$manifest = $this->compileManifest($providers);
}
foreach ($manifest['when'] as $provider => $events) {
// 注册包含有事件监听的service provider
// 包含有事件监听的service provider都要有when()函数返回
$this->registerLoadEvents($provider, $events);
}
foreach ($manifest['eager'] as $provider) {
// 把'eager'字段中service provider注册进容器中,
// 即遍历每一个service provider,调用其中的register()方法
// 向容器中注册具体的服务
$this->app->register($this->createProvider($provider));
}
// 注册延迟的service provider,
// deferred的service provider, 一是要设置$defer = true,二是要提供provides()方法返回绑定到容器中服务的名称
$this->app->addDeferredServices($manifest['deferred']);
}
看下编译缓存文件compileManifest()方法的源码:
protected function compileManifest($providers)
{
$manifest = $this->freshManifest($providers);
foreach ($providers as $provider) {
$instance = $this->createProvider($provider);
// 根据每一个service provider的defer属性看是否是延迟加载的service provider
if ($instance->isDeferred()) {
// 延迟加载的,根据provides()方法提供的服务名称,写入到'deferred'字段里
// 所以延迟加载的service provider都要提供provides()方法
foreach ($instance->provides() as $service) {
$manifest['deferred'][$service] = $provider;
}
// 使用when()函数提供的值注册下含有事件的service provider,
$manifest['when'][$provider] = $instance->when();
} else {
// 不是延迟加载的,就放在'eager'字段里,用$this->app->register()来注册延迟加载的service provider
$manifest['eager'][] = $provider;
}
}
// 最后写入到services.php缓存文件中
return $this->writeManifest($manifest);
}
protected function freshManifest(array $providers)
{
return ['providers' => $providers, 'eager' => [], 'deferred' => []];
}
总之,注册providers就是把config/app.php中$providers[ ]定义的所有service provider中,把不是defer的service provider中绑定的服务启动起来,是defer的service provider等到需要里面绑定的服务时再执行绑定。
7. 启动Providers
最后一步,就是启动程序了,看下BootProviders::bootstrap()源码:
public function bootstrap(Application $app)
{
$app->boot();
}
public function boot()
{
// 如果程序已启动则返回,显然还没启动,还在booting状态中
if ($this->booted) {
return;
}
// 执行之前Application实例化的时候在$bootingCallbacks[]注册的回调
$this->fireAppCallbacks($this->bootingCallbacks);
// 之前凡是用Application::register()方法的service provider都写入到了$serviceProviders[]中
// 这里依次执行每一个service provider里的boot()方法,如果存在的话
array_walk($this->serviceProviders, function ($p) {
$this->bootProvider($p);
});
$this->booted = true;
// 执行之前Application实例化的时候在$bootedCallbacks[]注册的回调
$this->fireAppCallbacks($this->bootedCallbacks);
}
protected function bootProvider(ServiceProvider $provider)
{
if (method_exists($provider, 'boot')) {
return $this->call([$provider, 'boot']);
}
}
从以上源码中知道,第(7)步和第(6)步类似:第(6)是依次执行每一个不是defer的service provider的register()方法;第(7)步是依次执行每一个不是defer的service provider的boot()方法,如果存在的话。所以官网上service provider章节说了这么一句The Boot Method:
This method is called after all other service providers have been registered, meaning you have access to all other services that have been registered by the framework
这里就明白了为啥这句话的含义了。
之前聊过Application::register()方法时里面有个检测程序是否已经启动的代码:
public function register($provider, $options = [], $force = false)
{
...
if ($this->booted) {
$this->bootProvider($provider);
}
return $provider;
}
刚刚开始实例化Application的时候还没有启动,在执行所有非defer的service provider boot()方法后程序就启动了:$this->booted = true;
。
OK, 程序启动所做的准备工作就聊完了,过程不复杂,只需一步步拆解就能基本清楚Laravel启动时做了哪些具体工作。
总结:本文主要学习了Laravel启动时做的七步准备工作:1. 环境检测 DetectEnvironment; 2. 配置加载 LoadConfiguratio; 3. 日志配置 ConfigureLogging; 4. 异常处理 HandleException;5. 注册Facades RegisterFacades;6. 注册Providers RegisterProviders;7. 启动Providers BootProviders。下次有好的技术再分享,到时见。
欢迎关注Laravel-China。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。