慢悠悠地Yii框架源码阅读(2) [ 2.0 版本 ]
Hello,大家好,大帅又来了。
第一篇文章里,竟然有人跟我反映文章写的太粗,那好,第二篇我尽量写的细节一点(此篇很长,慢慢嚼)。
上篇文章中,介绍了框架是如何自动加载类的,那么这篇文章怎么介绍,框架是如何通过路由解析,加载controller, layout和view的。
具体流程大概是这样的
- 加载相应的全局配置。
- 这里是列表文本解析请求URL的路由和参数
- 找到相应的controller
- 找到相应的layout
- 找到相应的view
- 最后发送请求
本片文章接着从入口文件@web/index.php中的最后一行 `
(new yii\web\Application($config))->run(); `
开始。
首先new yii\web\Application($config) ,自己可以写成这样,$myapp = new yii\web\Application($config); $myapp->run();
上篇文章中,已经介绍了框架中已经预先定义了一大批的类以及类对应的文件路径,入口文件中的前几行,分两部分,一部分是vendor里面的autoload预定义一些第三方的类,另一部分是Yii预定义自己的核心类文件,如@vendor/yiisoft/yii2/classes.php里面,很容易找到yii\web\Application对应的文件路径为YII2_PATH./web/Application.php (YII2_PATH的值已经在BaseYii.php文件里定义过了)
进入查看web/Application.php文件,发现Application继承与\yii\base\Application
继续查看\yii\base\Application 又继承与Module,而Module又继承与ServiceLocator,而ServiceLocator又继承与Component,而Component又继承与Object等等,自己查看吧,在此列出集成关系为(后续文章会对这些类的作用做个统计分析):
回过头来看入口文件index.php最后一行,new yii\web\Application($config); 很明显new了一个类,并将config/web.php文件中的数组,通过构造函数加载进框架中,查看代码之后,走的是base文件夹下的Application下的构造函数,
Yii::$app = $this,这句话其实很微妙,Yii是个全局类,而将Application实体放进了全局类的$app中了(这算不算注入了呢?),在很多地方我们会调用Yii::$app,就是调用了Application相对应的实体,也就是整个应用程序。$this->setInstance($this); 进入到setInstance($this)函数,调用的是Module.php文件中的setInstance函数,
此时走的是else里面那段代码,将该Application类放进已经加载过的模块数组中。
再次回到base\Application的构造函数中,接着进行$this->preInit($config)的分析,调用的base\Application中的preInit函数,注意参数用的引用传递,不是赋值传递,也就是说函数里面的修改会影响你自己写的配置文件哦。
该函数大致的流程是定义app的ID,设置basePath, 设置vendorPath, 设置runtimePath,设置timezone,与自定义的配置进行一个merge操作,里面会用到setAlias和getAlias,关于Alias的概念不再赘述,大概意思就是进行一个重命名。
首先来看basePath的设置,$this->setBasePath()先调用base/Application本身的setBasePath()
在这里面,先调用父类也就是Module.php中的setBasePath()
第一行中不用看,调用Yii::getAlias()函数返回的仍然是$path,应为getAlias会判断参数是不是以@开头,如果以@开头才会去全局的aliases数组里查找,接着就把$this->_basePath设置成了$path的值,接着回到base\Application.php的setBasePath()函数中,就把当前app的basePath设置到了@app缩写数组中,里面的$this->getBasePath()不再分析了,一看名字都知道啥意思了。这样的话,在回去看preInit()函数,接着就会配置vendorPath,同理不在分析了。接着说下preInit函数里面的最后几行,会和自定义的config做一个merge,以自定义为高优先级覆盖,具体可以参考代码。最后merge中的结果为(原本为数组,为了好看我做了个json_encode操作):
另外在说明一下coreComponents分成了两部分,一部分在web\Application.php中,一部分在base\Application.php中。最后生成的alias数组里面有这些内容:
接着回到construct函数中,接着要这句了$this->registerErrorHandler($config);这句话不再分析了,就是注册错误handler,没什么分析得了,你也可以自定义自己的errorhandler。
接着就是调用了Component::construct()函数,指向调用的是Object::construct(),
=========写到这里我是悲哀的,上传了十张图片之后不能上传了================
public function __construct($config = [])
{
if (!empty($config)) {
Yii::configure($this, $config);
}
$this->init();
}
里面调用的是Yii::configure($this, $config);
public static function configure($object, $properties)
{
foreach ($properties as $name => $value) {
$object->$name = $value;
}
return $object;
}
就是将config属性的值都赋给$app,有人会问,前面不是有设置配置了吗?前面的配置主要是为了设置别名,路径之类的。不要遗忘Object::__construct()函数里还有一句$this->init();接下来分析这句话。这里调用的是base\Application.php
public function init()
{
$this->state = self::STATE_INIT;
$this->bootstrap();
}
直接分析第二句话吧,$this->bootstrap(); (之前@lizhi353 她的源码分析中到这一步我觉得分析错了,她分析的是走的module里面的init(),我确认了一下,貌似走的是web/Application.php里面的init(),各位可以看看她的这块分析对不对http://www.yiichina.com/code/546)
protected function bootstrap()
{
$request = $this->getRequest();
Yii::setAlias('@webroot', dirname($request->getScriptFile()));
Yii::setAlias('@web', $request->getBaseUrl());
parent::bootstrap();
}
里面前几句设置了webroot的别名,web的别名,在前面的那个别名图例中可以找到他的值,另外$this->getRequest(),这个函数后面会讲到。
之后调用parent::bootstrap(),调用的是base\Application.php里面的bootstrap()
protected function bootstrap()
{
if ($this->extensions === null) {
$file = Yii::getAlias('@vendor/yiisoft/extensions.php');
$this->extensions = is_file($file) ? include($file) : [];
}
foreach ($this->extensions as $extension) {
if (!empty($extension['alias'])) {
foreach ($extension['alias'] as $name => $path) {
Yii::setAlias($name, $path);
}
}
if (isset($extension['bootstrap'])) {
$component = Yii::createObject($extension['bootstrap']);
if ($component instanceof BootstrapInterface) {
Yii::trace('Bootstrap with ' . get_class($component) . '::bootstrap()', __METHOD__);
$component->bootstrap($this);
} else {
Yii::trace('Bootstrap with ' . get_class($component), __METHOD__);
}
}
}
foreach ($this->bootstrap as $class) {
$component = null;
if (is_string($class)) {
if ($this->has($class)) {
$component = $this->get($class);
} elseif ($this->hasModule($class)) {
$component = $this->getModule($class);
} elseif (strpos($class, '\\') === false) {
throw new InvalidConfigException("Unknown bootstrapping component ID: $class");
}
}
if (!isset($component)) {
$component = Yii::createObject($class);
}
if ($component instanceof BootstrapInterface) {
Yii::trace('Bootstrap with ' . get_class($component) . '::bootstrap()', __METHOD__);
$component->bootstrap($this);
} else {
Yii::trace('Bootstrap with ' . get_class($component), __METHOD__);
}
}
}
里面加载了三个extension组件,分别为log,debug,gii;
yii\log\Dispatcher
yii\debug\Module
yii\gii\Module
其中,debug和gii会经过下面条件语句`
$component->bootstrap($this);`
具体不知道这几行是做什么的,初步分析了一下,拿debug来说,应该是进入debug页面的时候,系统默认了更改了路由规则,防止用户自定义的路由规则下找不到debug页面(具体分析后面补充一下)。
==================至此系统的所有配置已经基本加载完毕 ===================
先回顾下配置的加载,系统经过了三个步骤加载配置文件,第一步通过构造函数进行了id,basepath的最最基本的配置。第二部经过preInit()进行了系统组件的配置,如设置别名,设置路径,设置URL等还有一些第三方的配置merge,第三步是extension的配置。先设置不让你改的,在设置我需要的,最后设置没必要的,就是这么个逻辑。
========================接下来就是run了==========================
再次回到入口脚本,new完application之后,接下来就是执行run().知道对应真正执行的地方就是base\Application.php文件中,
public function run()
{
try {
$this->state = self::STATE_BEFORE_REQUEST;
$this->trigger(self::EVENT_BEFORE_REQUEST);
$this->state = self::STATE_HANDLING_REQUEST;
$response = $this->handleRequest($this->getRequest());
$this->state = self::STATE_AFTER_REQUEST;
$this->trigger(self::EVENT_AFTER_REQUEST);
$this->state = self::STATE_SENDING_RESPONSE;
$response->send();
$this->state = self::STATE_END;
return $response->exitStatus;
} catch (ExitException $e) {
$this->end($e->statusCode, isset($response) ? $response : null);
return $e->statusCode;
}
}
trigger那几行我没有研究,后续会研究Event和Component这些东东,但是直觉告诉我是触发接受请求之前做的事情,和接受请求之后做的事情,在此非常抱歉,就不做分析了。直接分析$response = $this->handleRequest($this->getRequest());这句代码吧。很明显需要两步走,第一步是找到getRequest(),它在base\Application.php中。
public function getRequest()
{
return $this->get('request');
}
此时触发的是$this->get('request'); 此函数在di\ServiceLocator.php文件中,
public function get($id, $throwException = true)
{
if (isset($this->_components[$id])) {
return $this->_components[$id];
}
if (isset($this->_definitions[$id])) {
$definition = $this->_definitions[$id];
if (is_object($definition) && !$definition instanceof Closure) {
return $this->_components[$id] = $definition;
} else {
return $this->_components[$id] = Yii::createObject($definition);
}
} elseif ($throwException) {
throw new InvalidConfigException("Unknown component ID: $id");
} else {
return null;
}
}
他会先找到_componentes[$id],拿getRequest()来说吧,就是找到配置文件中的request类,并且依照依赖注入的方式,将该类注入到_definitions中,方便以后调用的永远是这一份,而不是多份,避免资源浪费。另外注入的是对象,而不是类本身。回到run()函数里面,$response = $this->handleRequest($this->getRequest()); 该进行handleRequest()函数了。这个函数位于web\Application.php中。
public function handleRequest($request)
{
if (empty($this->catchAll)) {
list ($route, $params) = $request->resolve();
} else {
$route = $this->catchAll[0];
$params = $this->catchAll;
unset($params[0]);
}
try {
Yii::trace("Route requested: '$route'", __METHOD__);
$this->requestedRoute = $route;
$result = $this->runAction($route, $params); //进行了beforeAction 和 afterAction的操作 ,还有交付加载页面的操作。。。
if ($result instanceof Response) {
return $result;
} else {
$response = $this->getResponse();
if ($result !== null) {
$response->data = $result;
}
return $response;
}
} catch (InvalidRouteException $e) {
throw new NotFoundHttpException(Yii::t('yii', 'Page not found.'), $e->getCode(), $e);
}
}
先看第一个if语句,这句话牵扯到catchAll,在官方文档里都有介绍http://www.yiichina.com/doc/guide/2.0/structure-applications,
里面说的很清楚了,下线的时候或者维护暂时下线的时候,可以将所有的路由都导向这个配置中,然后自定义自己的页面。为什么会这样,原理,就是刚才说的这个,在handleRequest()函数中,首先会检查这个配置的,所以你访问哪个页面,都会走这个验证,并且系统通过你catchAll的配置,后台改写你的路由。如果没有配置,那就按照正常的路由解析往下走。分析中按照没有catchAll来进行。不过通过官方文档中的配置可以推断出$request->resolve()函数,在正常的路由下,解析出来$route结果应该就是一个字符串,好 ,我们进入到$request->resolve()函数中。因为前面的配置图例中可以看出request类其实是web\Request类,在web\Request类中,找到了该函数。
public function resolve()
{
$result = Yii::$app->getUrlManager()->parseRequest($this);
if ($result !== false) {
list ($route, $params) = $result;
if ($this->_queryParams === null) {
$_GET = $params + $_GET; // preserve numeric keys
} else {
$this->_queryParams = $params + $this->_queryParams;
}
return [$route, $this->getQueryParams()];
} else {
throw new NotFoundHttpException(Yii::t('yii', 'Page not found.'));
}
}
getUrlManager()函数类似于前面的getRequest(),不再赘述。找到web\Urlmanager.php的parseRequest()函数。
public function parseRequest($request)
{
if ($this->enablePrettyUrl) {
$pathInfo = $request->getPathInfo();
/* @var $rule UrlRule */
foreach ($this->rules as $rule) {
if (($result = $rule->parseRequest($this, $request)) !== false) {
return $result;
}
}
if ($this->enableStrictParsing) {
return false;
}
Yii::trace('No matching URL rules. Using default URL parsing logic.', __METHOD__);
$suffix = (string) $this->suffix;
if ($suffix !== '' && $pathInfo !== '') {
$n = strlen($this->suffix);
if (substr_compare($pathInfo, $this->suffix, -$n, $n) === 0) {
$pathInfo = substr($pathInfo, 0, -$n);
if ($pathInfo === '') {
// suffix alone is not allowed
return false;
}
} else {
// suffix doesn't match
return false;
}
}
return [$pathInfo, []];
} else {
Yii::trace('Pretty URL not enabled. Using default URL parsing logic.', __METHOD__);
$route = $request->getQueryParam($this->routeParam, '');
if (is_array($route)) {
$route = '';
}
return [(string) $route, []];
}
}
我的配置默认走的是else语句里面的步骤。前面那一坨跟你的URL美化配置有关,在此不做分析。$this->routeParam指向的是Request.php文件的routeParam属性,查看文件之后就会看到,默认定义的为'r',这个r,凡是用过框架的人估计都是知道的。
调用的是web\Request里面的getQueryParam()
public function getQueryParam($name, $defaultValue = null)
{
$params = $this->getQueryParams();
return isset($params[$name]) ? $params[$name] : $defaultValue;
}
$params = $this->getQueryParams()在代码里返回的就是$_GET数组。然后返回了浏览器里面r的参数。回到Request文件的resolve函数,得到$route就是请求URL里面的r参数值。最后返回到resolve()返回的是类似于下面这个数据。
Array
(
[0] => site/about
[1] => [
[r] => site/about
]
)
数组中的第一个元素里面主要是路由信息,第二个参数会解析url里面的所有参数,详见getQueryParams()函数。
从resolve()再出去(返回)之后,回到web\Application.php中的handleRequest()函数里面,
try {
Yii::trace("Route requested: '$route'", __METHOD__);
$this->requestedRoute = $route;
$result = $this->runAction($route, $params); //进行了beforeAction 和 afterAction的操作 ,还有交付加载页面的操作。。。
if ($result instanceof Response) {
return $result;
} else {
$response = $this->getResponse();
if ($result !== null) {
$response->data = $result;
}
return $response;
}
}
$this->requestedRoute的值为'site/index',接下来进行$result = $this->runAction($route, $params); 的分析。查找runAction函数,指向了Module.php里面的runAction函数。
public function runAction($route, $params = [])
{
$parts = $this->createController($route);
//$route = site/index
//$params = ['r' => 'site/index', 'id' => 1]
if (is_array($parts)) {
/* @var $controller Controller */
list($controller, $actionID) = $parts;
//$controller = app\controllers\SiteController
//$actionID = 'index'
$oldController = Yii::$app->controller;
Yii::$app->controller = $controller;
$result = $controller->runAction($actionID, $params);
Yii::$app->controller = $oldController;
return $result;
} else {
$id = $this->getUniqueId();
throw new InvalidRouteException('Unable to resolve the request "' . ($id === '' ? $route : $id . '/' . $route) . '".');
}
}
先分析第一句$parts = $this->createController($route);
这个函数还是在Module.php中
public function createController($route)
{
if ($route === '') {
$route = $this->defaultRoute;
}
// double slashes or leading/ending slashes may cause substr problem
$route = trim($route, '/');
if (strpos($route, '//') !== false) {
return false;
}
if (strpos($route, '/') !== false) {
list ($id, $route) = explode('/', $route, 2);
} else {
$id = $route;
$route = '';
}
// module and controller map take precedence
if (isset($this->controllerMap[$id])) {
$controller = Yii::createObject($this->controllerMap[$id], [$id, $this]);
return [$controller, $route];
}
$module = $this->getModule($id);
if ($module !== null) {
return $module->createController($route);
}
if (($pos = strrpos($route, '/')) !== false) {
$id .= '/' . substr($route, 0, $pos);
$route = substr($route, $pos + 1);
}
$controller = $this->createControllerByID($id);
if ($controller === null && $route !== '') {
$controller = $this->createControllerByID($id . '/' . $route);
$route = '';
}
return $controller === null ? false : [$controller, $route];
}
前几句先把site/index这样的route参数值分割成id和route,接下来,controller是可以通过controllerMap在config/web.php里面进行配置的,在这里就会通过解析映射到配置结果。咱们在这边分析的话按照没有配置继续往下走,会进入到getModule()函数里面。需要注意的是在list($id, $route) = explode('/', $route, 2);里面有个参数2.
public function getModule($id, $load = true)
{
if (($pos = strpos($id, '/')) !== false) {
// sub-module
$module = $this->getModule(substr($id, 0, $pos));
return $module === null ? null : $module->getModule(substr($id, $pos + 1), $load);
}
if (isset($this->_modules[$id])) {
if ($this->_modules[$id] instanceof Module) {
return $this->_modules[$id];
} elseif ($load) {
Yii::trace("Loading module: $id", __METHOD__);
/* @var $module Module */
$module = Yii::createObject($this->_modules[$id], [$id, $this]);
$module->setInstance($module);
return $this->_modules[$id] = $module;
}
}
return null;
}
我这边默认的应该是null,接下来返回到createController函数里面的$controller = $this->createControllerByID($id);那么就进入到createControllerByID($ID)里面吧,
public function createControllerByID($id)
{
$pos = strrpos($id, '/');
if ($pos === false) {
$prefix = '';
$className = $id;
} else {
$prefix = substr($id, 0, $pos + 1);
$className = substr($id, $pos + 1);
}
if (!preg_match('%^[a-z][a-z0-9\\-_]*$%', $className)) {
return null;
}
if ($prefix !== '' && !preg_match('%^[a-z0-9_/]+$%i', $prefix)) {
return null;
}
$className = str_replace(' ', '', ucwords(str_replace('-', ' ', $className))) . 'Controller';
$className = ltrim($this->controllerNamespace . '\\' . str_replace('/', '\\', $prefix) . $className, '\\');
if (strpos($className, '-') !== false || !class_exists($className)) {
return null;
}
if (is_subclass_of($className, 'yii\base\Controller')) {
$controller = Yii::createObject($className, [$id, $this]);
return get_class($controller) === $className ? $controller : null;
} elseif (YII_DEBUG) {
throw new InvalidConfigException("Controller class must extend from \\yii\\base\\Controller.");
} else {
return null;
}
}
注意这一句$className = str_replace(' ', '', ucwords(str_replace('-', ' ', $className))) . 'Controller';这个主要是关系到命名和大小写问题,这个不做详解,具体可以参考这片文章http://www.yiichina.com/doc/guide/2.0/structure-controllers。接下来就是获得$className,起初我以为controllerNamespace没有值,然后调试的时候打印出来竟是app\Controllers,我以为是我分析的时候漏了什么地方,找了些许时间,发现该值竟然在base\Application.php定义的默认值。最后会得到这样的结果,app\controllers\SiteController,之后再判断该类是不是yii\base\Controller的子类,之后进行createObject操作,这样的话就得到了相应的Controller。返回到runAction里面第一行代码里,最后$parts的结果是一个controller的对象,和route字符串构成的数组。看我的截图里面,有一个默认的结果注释。之后进行controller的runAction函数。到此为止可以推断,Module的作用主要是找到相应的controller并进行一个实例化。而controller的runAction主要是找到相应的action函数。接着往下进行,$result = $controller->runAction($actionID, $params); 而这个runAction函数指向的是base\Controller.php文件中
public function runAction($id, $params = [])
{
//$id = 'index';
//$params = ['r' => 'site/index', 'id' => 1]
$action = $this->createAction($id);
if ($action === null) {
throw new InvalidRouteException('Unable to resolve the request: ' . $this->getUniqueId() . '/' . $id);
}
Yii::trace('Route to run: ' . $action->getUniqueId(), __METHOD__);
if (Yii::$app->requestedAction === null) {
Yii::$app->requestedAction = $action;
}
$oldAction = $this->action;
$this->action = $action;
$modules = [];
$runAction = true;
// call beforeAction on modules
foreach ($this->getModules() as $module) {
if ($module->beforeAction($action)) {
array_unshift($modules, $module);
} else {
$runAction = false;
break;
}
}
$result = null;
if ($runAction && $this->beforeAction($action)) {
// run the action
$result = $action->runWithParams($params);
$result = $this->afterAction($action, $result);
// call afterAction on modules
foreach ($modules as $module) {
/* @var $module Module */
$result = $module->afterAction($action, $result);
}
}
$this->action = $oldAction;
return $result;
}
里面标注的都有默认注释值,方便跟踪debug查看。先看第一行$action = $this->createAction($id),
public function createAction($id)
{
if ($id === '') {
$id = $this->defaultAction;
}
$actionMap = $this->actions();
if (isset($actionMap[$id])) {
return Yii::createObject($actionMap[$id], [$id, $this]);
} elseif (preg_match('/^[a-z0-9\\-_]+$/', $id) && strpos($id, '--') === false && trim($id, '-') === $id) {
$methodName = 'action' . str_replace(' ', '', ucwords(implode(' ', explode('-', $id))));
if (method_exists($this, $methodName)) {
$method = new \ReflectionMethod($this, $methodName);
if ($method->isPublic() && $method->getName() === $methodName) {
return new InlineAction($id, $this, $methodName);
}
}
}
return null;
}
找到这个函数如图所示,来看这句话,$actionMap = $this->actions(),这个函数对应的controller.php中的actions()函数,这个函数,可以再自己的controller里面进行详细配置,从而进行一个映射,这里不做分析。这里返回的是一个InlineAction对象。回到runAction里面,接着进行一个beforeAction的函数。这些before和after的不做分析,直接看$action->runWithParams()函数。这个调用的是InlineAction.php里面的runWithParams();
public function runWithParams($params)
{
$args = $this->controller->bindActionParams($this, $params);
Yii::trace('Running action: ' . get_class($this->controller) . '::' . $this->actionMethod . '()', __METHOD__);
if (Yii::$app->requestedParams === null) {
Yii::$app->requestedParams = $args;
}
return call_user_func_array([$this->controller, $this->actionMethod], $args);
}
进入之后,第一句调用的是web\Controller.php里面的bindActionParams(), 看函数名字就知道是绑定action方法的参数,在此不做详细分析了。最后一句是执行controller里面的类似于actionIndex这样的函数。这样找到了controller里面相应的action函数,需要说明的是,如果没有return $this->render***这类函数的话,你就得不到结果。在这里render渲染函数有很多,如render,renderPartical, renderFile, renderAjax等。这个函数就是拿controller里面的action作为入口,接下来就进行了找到layout,和相应的view文件,进行渲染,这里放进下一篇文章里进行分析。得到结果之后,我们返回到base\runAction函数里继续往下走,就会进行afterAction函数,这里也不做详细分析。然后再往前返回的话,回到了web\Application.php中的handleRequest(),
try {
Yii::trace("Route requested: '$route'", __METHOD__);
$this->requestedRoute = $route;
$result = $this->runAction($route, $params); //进行了beforeAction 和 afterAction的操作 ,还有交付加载页面的操作。。。
if ($result instanceof Response) {
return $result;
} else {
$response = $this->getResponse();
if ($result !== null) {
$response->data = $result;
}
return $response;
}
}
得到了相应的页面内容,当然在进行controller的实例化,action的执行的过程中,会根据用户自己的代码,进行相应的model和view加载和操作。这些模块不算是流程中必须的架构,只是一些组件加载进去了。继续回到代码,就进行了$response = $this->getResponse(),这个getResponse跟刚开始的getRequest类似,通过serviceLocater进行单次实例化,你也可以在这个步骤,操作HTTP一些头和格式化数据类型。
之后再重新回到base\Application.php的run函数,就是`
$response->send();`
将$result的数据进行发送。交付给应用程序,之后就返回给了用户。
整个过程,就这么结束了。
大概流程就是:
- 加载相应的全局配置。
- 这里是列表文本解析请求URL的路由和参数
- 找到相应的controller
- 找到相应的layout
- 找到相应的view
- 最后发送请求
接下来会详细分析文章内提到的以controller里面的action[ID]函数进行layout和view的查找和渲染。
敬请期待,希望各位多多鼓励,多多赞!
张大帅
最后登录:2023-06-09
在线时长:38小时16分
- 粉丝94
- 金钱3865
- 威望50
- 积分4745
热门源码
- 基于 Yii 2 + Bootstrap 3 搭建一套后台管理系统 CMF
- 整合完 yii2-rbac+yii2-admin+adminlte 等库的基础开发后台源码
- 适合初学者学习的一款通用的管理后台
- yii-goaop - 将 goaop 集成到 Yii,在 Yii 中优雅的面向切面编程
- yii-log-target - 监控系统异常且多渠道发送异常信息通知
- 店滴云1.3.0
- 面向对象的一小步:添加 ActiveRecord 的 Scope 功能
- Yii2 开源商城 FecShop
- 基于 Yii2 开发的多店铺商城系统,免费开源 + 适合二开
- leadshop - 基于 Yii2 开发的一款免费开源且支持商业使用的商城管理系统
共 11 条评论
good article!
写的好长啊,辛苦了。
好文好文,学习了,大神
这个有点儿厉害的,
大帅真是厉害
前排留言,此贴必火.虽然现在还有很多看不懂,实属自己技术不够.楼主辛苦
感谢您的支持
竟然有心写这长的文章,楼主用心良苦
感谢您的支持
辛苦,辛苦!
楼主辛苦了
良心之作!~
怒赞楼主.
thanks