PHP现代化框架探秘

ollie_frame

其实我是来交作业的,之前看着这位老哥写的文章[手摸手带你创建php现代化框架] 教程终于写完了,也跟着完成了相关的教程,所以就想着自己也尝试写一下框架的流程和构建。项目github地址lxzzze/ollie_frame.如果需要可以自行下载阅读研究代码。后续可能会在此基础上写一个简单的项目再修改修改。当然如果有什么错误的地方,也欢迎指出,一起探讨学习。

一个手动构建的现代化PHP框架(当然只是为了学习)

通过一步步搭建框架的每个部分,了解框架内部基本的实现原理思路,尝试编写相关代码去实现功能,或者引用别人写好的第三方包,查看其源代码了解实现原理,并拼装到框架当中,下面开始一步步介绍框架的各个组成。以下讲解说明的内容不会贴所有的代码,只会将一些比较重要的代码块拿出来说明。如果需要可以看github地址的完整项目代码。

单一入口

跟正常的现代化框架一样,所有请求通过单一入口进入,文件位于./index.php文件中

服务容器

参考DennisRitche/php-base-container
深入理解了相关服务容器的核心,个人理解核心是通过添加$bind,绑定类的实现或类的映射,若绑定类的映射,
服务容器会通过反射类完成对类的实例化过程。

容器类代码位于./core/Container.php文件

核心方法为get(),bind(),下面为相关代码,其中bind()方法为绑定对象,向容器中添加类方法的映射或实例闭包,方便后续get()方法获取类的实例。get()方法用于获取已绑定在$bind变量中的类,并将其返回实例化,其中$is_singleton可指定为单例对象,若为true,会将该类添加到$instances变量中,后续再调用该类时,直接从$instances变量中获取,类还是之前那个类。

    //获取指定类的实例
    public function get($name,$real_args = [])
    {
        //检查实例是否存在,已存在则直接返回
        if (isset($this->instances[$name])){
            return $this->instances[$name];
        }
        //检查是否绑定该类和当前类是否存在
        if (!isset($this->binds[$name]) && !isset($this->instances[$name])){
            if (!class_exists($name,true)){
                throw new \InvalidArgumentException('class not exists');
            }
        }
        if (isset($this->binds[$name])){
            if (is_callable($this->binds[$name]['concrete'])){
                $instance = $this->call($this->binds[$name]['concrete'],$real_args);
            }else{
                $instance = $this->build($name,$real_args);
            }
        }else{
            $instance = $this->build($name,$real_args);
        }

        //是否为单例,将其对象添加到绑定数组中
        if ($this->binds[$name]['is_singleton'] == true){
            $this->instances[$name] = $instance;
        }
        return $instance;

    }

    //将对象名和创建对象的闭包添加到绑定对象数组
    public function bind($name,$concrete,$is_singleton = false)
    {
        if ($concrete instanceof \Closure) {
            $this->binds[$name] = ['concrete' => $concrete, "is_singleton" => $is_singleton];
        } else {
            if (!is_string($concrete) || !class_exists($concrete, true)) {
                throw new \InvalidArgumentException("value must be callback or class name");
            }
        }

        $this->binds[$name] = ['concrete' => $concrete, "is_singleton" => $is_singleton];
    }

服务提供者

这里引入服务提供者的概念,每个服务添加一个服务提供者,服务提供者基础一个统一继承接口./core/providers/ServiceProviderInterface.php

实现接口方法,注册服务register();启用服务boot()

其中主要实现register方法,去实现bind()方法,向容器中添加绑定具体实现类

class ConfigServiceProvider implements ServiceProviderInterface
{
    //注册服务
    public function register()
    {
        app()->bind('config',function (){
            return new Config();
        },true);
    }

    //加载服务
    public function boot()
    {
        app('config');
    }

}

定义全局函数

在composer.json中添加自动加载文件,如下添加配置


"autoload": {
        "files": [
            "./helper.php"
        ]
   },

然后执行composer auto-dumpload

如下为我添加的一个可全局使用的函数.函数作用返回容器实例或容器服务实例

if (!function_exists('app')){
    //获取app容器服务
    function app($name = null){
        if (!$name){
            return \core\Container::getContainer();
        }
        return \core\Container::getContainer()->get($name);
    }
}

添加信息调试工具

通过命令composer require symfony/var-dumper引入第三方包

这样就可以在项目中,跟laravel框架一样使用dd(),dump()等函数打印调试

添加配置

在系统中封装一个config()全局函数,实现类似laravel的config目录下添加配置,并添加.env文件实现对私密信息的隐藏封装

创建./config目录,在目录下创建app.php,文件内容同laravel的config目录下文件保持一致,使用如下直接return

<?php

return [
    'name' => 'ollie',
    'db' => [
        'name' => 'test'
    ],
    //服务提供者
    'providers' => [
        \core\providers\RoutingServiceProvider::class,
        \core\providers\ViewServiceProvider::class,
        \core\providers\ResponseServiceProvider::class,
        \core\providers\RequestServiceProvider::class,
        \core\providers\LogServiceProvider::class,
        \core\providers\DBServiceProvider::class
    ]

];

在./core目录下创建Config.php文件,创建config类,类主要功能是对配置文件的获取,通过get()函数获取config目录下,各个文件配置的获取
,如获取app.php文件下的name,应传入参数get(‘app.name’),获取db.name,则传入get(‘app.db.name’)

定义全局函数,便于通过config(‘app.name’)这样的方式获取配置

if (!function_exists('config')){
    //获取配置文件信息
    function config($name = null){
        if (!$name){
            return null;
        }
        return app('config')->get($name);
    }
}

引入.env配置

vlucas/phpdotenv

通过引用composer require vlucas/phpdotenv第三方包

在./core/config.php文件中引入,如下并创建.env文件

public function __construct()
{
    //加载env文件
    $dotenv = Dotenv::createImmutable(FRAME_BASE_PATH);
    $dotenv->load();
}

在查看其第三方源码过程中,研究发现其主要实现是通过./vendor/vlucas/phpdotenv/src/Parser/Parser.php中的
parse()函数,其中$content为.env文件内容,将读取的.env文件内容通过正则表达式解析返回

public function parse(string $content)
{
    return Regex::split("/(\r\n|\n|\r)/", $content)->mapError(static function () {
        return 'Could not split into separate lines.';
    })->flatMap(static function (array $lines) {
        return self::process(Lines::process($lines));
    })->mapError(static function (string $error) {
        throw new InvalidFileException(\sprintf('Failed to parse dotenv file. %s', $error));
    })->success()->get();
}

然后通过./vendor/vlucas/src/Loader/Loader.php中的load()函数将$_ENV设置成.env中添加的变量值

public function load(RepositoryInterface $repository, array $entries)
{
    return \array_reduce($entries, static function (array $vars, Entry $entry) use ($repository) {
        $name = $entry->getName();
        $value = $entry->getValue()->map(static function (Value $value) use ($repository) {
            return Resolver::resolve($repository, $value);
        });
        if ($value->isDefined()) {
            $inner = $value->get();
            if ($repository->set($name, $inner)) {
                return \array_merge($vars, [$name => $inner]);
            }
        } else {
            if ($repository->clear($name)) {
                return \array_merge($vars, [$name => null]);
            }
        }

        return $vars;
    }, []);
}

最终结果呢,是调用了./vendor/vlucas/phpdotenv/src/Repository/Adapter/EnvConstAdapter.php文件中的write()方法。

public function write(string $name, string $value)
{
    $_ENV[$name] = $value;

    return true;
}

定义全局函数,由于env()函数命名与第三方包命名有冲突,这里封装env1()函数,便于全局使用.env文件配置

if (!function_exists('env1')){
    //获取env配置文件信息
    function env1($name = null,$default = null){
        if (!$name){
            return null;
        }
        if (isset($_ENV[$name])){
            return $_ENV[$name];
        }
        return $default;
    }
}

请求

通过引用composer require laminas/laminas-diactoros第三方包,将request请求封装成一个对象操作

添加./core/providers/RequestServiceProvider请求服务提供者


use Laminas\Diactoros\ServerRequestFactory;
...
...
//注册服务
public function register()
{
    app()->bind('request',function (){
        return ServerRequestFactory::fromGlobals(
            $_SERVER, $_GET, $_POST, $_COOKIE, $_FILES
        );
    },true);
}

该扩展包内部代码的大致操作就是将$_SERVER,$_GET,$_POST,$_COOKIE,$_FILES等相关超全局变量赋值给类中的相应变量

以下为打印返回的request变量信息,可方便后续在开发过程中,通过该第三方包对请求的封装,对request进行统一处理

Laminas\Diactoros\ServerRequest {#38
  -attributes: []
  -cookieParams: []
  -parsedBody: array:2 [
    "a" => "1213"
    "b" => "2132"
  ]
  -queryParams: array:1 [
    "id" => "121"
  ]
  -serverParams: array:41 [
    "USER" => "tengtengcai"
    "HOME" => "/Users/tengtengcai"
    "HTTP_CONTENT_LENGTH" => "22685"
    "HTTP_CONTENT_TYPE" => "multipart/form-data; boundary=--------------------------962405142584130369455706"
    "HTTP_CONNECTION" => "keep-alive"
    "HTTP_ACCEPT_ENCODING" => "gzip, deflate, br"
    "HTTP_HOST" => "ollie.test"
    "HTTP_POSTMAN_TOKEN" => "98c248ae-5d5f-45dd-879c-5ac2a788fb6a"
    "HTTP_CACHE_CONTROL" => "no-cache"
    "HTTP_ACCEPT" => "*/*"
    "HTTP_USER_AGENT" => "PostmanRuntime/7.26.5"
    "REDIRECT_STATUS" => "200"
    "SERVER_NAME" => "ollie.test"
    "SERVER_PORT" => "80"
    "SERVER_ADDR" => "127.0.0.1"
    "REMOTE_PORT" => "55592"
    "REMOTE_ADDR" => "127.0.0.1"
    "SERVER_SOFTWARE" => "nginx/1.17.1"
    "GATEWAY_INTERFACE" => "CGI/1.1"
    "SERVER_PROTOCOL" => "HTTP/1.1"
    "DOCUMENT_ROOT" => "/Users/tengtengcai/sites/ollie"
    "DOCUMENT_URI" => "/Users/tengtengcai/.composer/vendor/laravel/valet/server.php"
    "REQUEST_URI" => "/?id=121"
    "SCRIPT_NAME" => "/index.php"
    "SCRIPT_FILENAME" => "/Users/tengtengcai/sites/ollie/index.php"
    "CONTENT_LENGTH" => "22685"
    "CONTENT_TYPE" => "multipart/form-data; boundary=--------------------------962405142584130369455706"
    "REQUEST_METHOD" => "POST"
    "QUERY_STRING" => "id=121"
    "FCGI_ROLE" => "RESPONDER"
    "PHP_SELF" => "/"
    "REQUEST_TIME_FLOAT" => 1606125093.5989
    "REQUEST_TIME" => 1606125093
  ]
  -uploadedFiles: array:1 [
    "c" => Laminas\Diactoros\UploadedFile {#42
      -clientFilename: "u=1035415831,1465727770&fm=26&gp=0.jpg"
      -clientMediaType: "image/jpeg"
      -error: 0
      -file: "/private/var/tmp/phpqeRBwc"
      -moved: false
      -size: 22244
      -stream: null
    }
  ]
  -method: "POST"
  -requestTarget: null
  -uri: Laminas\Diactoros\Uri {#35
    #allowedSchemes: array:2 [
      "http" => 80
      "https" => 443
    ]
    -scheme: "http"
    -userInfo: ""
    -host: "ollie.test"
    -port: null
    -path: "/"
    -query: "id=121"
    -fragment: ""
    -uriString: null
  }
  #headers: array:9 [
    "content-length" => array:1 [
      0 => "22685"
    ]
    "content-type" => array:1 [
      0 => "multipart/form-data; boundary=--------------------------962405142584130369455706"
    ]
    "connection" => array:1 [
      0 => "keep-alive"
    ]
    "accept-encoding" => array:1 [
      0 => "gzip, deflate, br"
    ]
    "host" => array:1 [
      0 => "ollie.test"
    ]
    "postman-token" => array:1 [
      0 => "98c248ae-5d5f-45dd-879c-5ac2a788fb6a"
    ]
    "cache-control" => array:1 [
      0 => "no-cache"
    ]
    "accept" => array:1 [
      0 => "*/*"
    ]
    "user-agent" => array:1 [
      0 => "PostmanRuntime/7.26.5"
    ]
  ]
  #headerNames: array:9 [
    "content-length" => "content-length"
    "content-type" => "content-type"
    "connection" => "connection"
    "accept-encoding" => "accept-encoding"
    "host" => "host"
    "postman-token" => "postman-token"
    "cache-control" => "cache-control"
    "accept" => "accept"
    "user-agent" => "user-agent"
  ]
  -protocol: "1.1"
  -stream: Laminas\Diactoros\PhpInputStream {#33
    -cache: ""
    -reachedEof: false
    #resource: stream resource @10
      timed_out: false
      blocked: true
      eof: false
      wrapper_type: "PHP"
      stream_type: "Input"
      mode: "rb"
      unread_bytes: 0
      seekable: true
      uri: "php://input"
      options: []
    }
    #stream: "php://input"
  }
}

路由系统

实际上路由系统,请求,响应,中间件等功能都是使用这里推荐的route.thephpleague

通过引用composer require league/route第三方包路由系统,由于该第三方包对响应进行了限制,只允许返回ResponseInterface接口,所以需要再引入其开发的响应包composer require laminas/laminas-httphandlerrunner,相关案例可查看route.thephpleague

根据官方给的案例,对源码进行分析

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

//实例化请求类
$request = Laminas\Diactoros\ServerRequestFactory::fromGlobals(
    $_SERVER, $_GET, $_POST, $_COOKIE, $_FILES
);

$router = new League\Route\Router;

//添加一个路由
$router->map('GET', '/', function (ServerRequestInterface $request) : ResponseInterface {
    $response = new Laminas\Diactoros\Response;
    $response->getBody()->write('<h1>Hello, World!</h1>');
    return $response;
});

//路由分发
$response = $router->dispatch($request);

//返回响应
(new Laminas\HttpHandlerRunner\Emitter\SapiEmitter)->emit($response);

对于\Legue\Route\Router对象中的map方法,其内部大致流程就是将定义的路由信息存放在一个$routes变量中

/**
 * {@inheritdoc}
 */
public function map(string $method, string $path, $handler): Route
{
    $path  = sprintf('/%s', ltrim($path, '/'));
    $route = new Route($method, $path, $handler);

    $this->routes[] = $route;

    return $route;
}

再来看看dispatch方法


/**
 * {@inheritdoc}
 */
//路由分发,这里限定了传入参数,所以这里传入的参数必须为$request对象
public function dispatch(ServerRequestInterface $request): ResponseInterface
{
    //这里设置了参数。。。具体要干嘛还不确定
    if ($this->getStrategy() === null) {
        $this->setStrategy(new ApplicationStrategy);
    }
    //准备路由,对定义的路由进行解析
    $this->prepRoutes($request);

    /** @var Dispatcher $dispatcher */
    //实例化分发路由对象,设置基本信息,并传入路由信息
    $dispatcher = (new Dispatcher($this->getData()))->setStrategy($this->getStrategy());
    //支持路由中间件的使用,后续再研究吧
    foreach ($this->getMiddlewareStack() as $middleware) {
        if (is_string($middleware)) {
            $dispatcher->lazyMiddleware($middleware);
            continue;
        }

        $dispatcher->middleware($middleware);
    }
    //最重要的一步,执行路由请求分发
    return $dispatcher->dispatchRequest($request);
}

再来看看dispatchRequest方法,方法的作用为调度当前路由

/**
 * Dispatch the current route
 *
 * @param ServerRequestInterface $request
 *
 * @return ResponseInterface
 */
public function dispatchRequest(ServerRequestInterface $request): ResponseInterface
{
    //获取当前请求方法
    $httpMethod = $request->getMethod();
    //获取当前请求url
    $uri        = $request->getUri()->getPath();
    //匹配当前路由
    $match      = $this->dispatch($httpMethod, $uri);
    //匹配分为三部分,未匹配成功,请求方法不合法,匹配成功,这里主要看匹配成功的情况
    switch ($match[0]) {
        //未匹配成功
        case FastRoute::NOT_FOUND:
            $this->setNotFoundDecoratorMiddleware();
            break;
        //请求方法不合法
        case FastRoute::METHOD_NOT_ALLOWED:
            $allowed = (array) $match[1];
            $this->setMethodNotAllowedDecoratorMiddleware($allowed);
            break;
        //匹配成功
        case FastRoute::FOUND:
            //确保路由定义的handle变量符合规范可以执行
            $route = $this->ensureHandlerIsRoute($match[1], $httpMethod, $uri)->setVars($match[2]);
            //重新路由是否设置了中间件
            $this->setFoundMiddleware($route);
            //添加路由变量作为请求属性
            $request = $this->requestWithRouteAttributes($request, $route);
            break;
    }
    //处理执行handle
    return $this->handle($request);
}

这里添加一个RoutingServiceProvider路由服务提供者,创建./routes/web.php文件,将路由的定义写在这里

//启用路由服务,这样就可以在web.php中添加路由定义了
public function boot()
{
    $router = app('router');
    foreach ($this->mapRoutes as $route){
        call_user_func($this->$route(),$router);
    }
}


public function mapWebRoutes()
{
    return function ($router){
        require_once 'routes/web.php';
    };
}

中间件

在使用路由系统的第三方包支持了中间件的使用,具体案例可以参考第三方包给的文档middleware

创建./app/middleware目录,在这个目录去添加相关的中间件,这里添加一个测试中间件TestMiddleware

class TestMiddleware implements MiddlewareInterface
{

    /**
     * @inheritDoc
     */
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $response = $handler->handle($request);
        echo 'test'."\n";
        // do something with the response
        return $response;
    }
}

//根据第三方包文档,将中间件引入可以全局引入,按组引入和针对单个路由引入,我这里测试在./core/providers/RoutingServiceProvider中添加全局引入
app('router')->middleware(new TestMiddleware());

这里尝试分析一下源码的中间件是如何实现的

当我们为路由添加中间件的时候,会调用./vendor/league/route下面两个方法,这里就相当于为$this->middleware变量赋值

/**
 * {@inheritdoc}
 */
public function middleware(MiddlewareInterface $middleware): MiddlewareAwareInterface
{
    $this->middleware[] = $middleware;

    return $this;
}

/**
 * {@inheritdoc}
 */
public function middlewares(array $middlewares): MiddlewareAwareInterface
{
    foreach ($middlewares as $middleware) {
        $this->middleware($middleware);
    }

    return $this;
}

这里通过dd打印app(‘route’)变量,如下所示,我们定义的路由为routes变量,这里定义了两个路由,其中定义的全局作用的中间件为middleware,针对单个路由的会存放在routes中的middleware变量中,打印后,可以清楚的看见我们为路由定义的变量都存放在哪里

^ League\Route\Router {#38 ▼
  #routes: array:2 [0 => League\Route\Route {#40 ▼
      #handler: "App\Controller\TestController::index"
      #group: null
      #method: "GET"
      #path: "/"
      #vars: []
      #middleware: array:1 []
      #host: null
      #name: null
      #scheme: null
      #port: null
      #strategy: League\Route\Strategy\ApplicationStrategy {#34}
    }
    1 => League\Route\Route {#35 ▼
      #handler: "App\Controller\TestController::about"
      #group: null
      #method: "GET"
      #path: "/about"
      #vars: []
      #middleware: []
      #host: null
      #name: null
      #scheme: null
      #port: null
      #strategy: League\Route\Strategy\ApplicationStrategy {#34}
    }
  ]
  #namedRoutes: []
  #groups: []
  #patternMatchers: array:5 []
  #routeParser: FastRoute\RouteParser\Std {#42}
  #dataGenerator: FastRoute\DataGenerator\GroupCountBased {#29}
  #currentGroupPrefix: ""
  #middleware: array:2 []
  #strategy: League\Route\Strategy\ApplicationStrategy {#34}
}

再来看看路由分发方法dispatch(),这里的$dispatcher->lazyMiddleware($middleware);$dispatcher->middleware($middleware);为$dispatcher变量赋值全局中间件,剩余的dispatchRequest()会匹配设置针对单个路由或组的中间件

public function dispatch(ServerRequestInterface $request): ResponseInterface
{
    if ($this->getStrategy() === null) {
        $this->setStrategy(new ApplicationStrategy);
    }

    $this->prepRoutes($request);

    /** @var Dispatcher $dispatcher */
    $dispatcher = (new Dispatcher($this->getData()))->setStrategy($this->getStrategy());
    //获取全局中间件,将全局中间价的变量赋值给$dispatcher中
    foreach ($this->getMiddlewareStack() as $middleware) {
        if (is_string($middleware)) {
            $dispatcher->lazyMiddleware($middleware);
            continue;
        }

        $dispatcher->middleware($middleware);
    }
    return $dispatcher->dispatchRequest($request);
}

这里是执行的核心,包含执行中间件和控制器的流程

/**
 * {@inheritdoc}
 */
public function handle(ServerRequestInterface $request): ResponseInterface
{
    $middleware = $this->shiftMiddleware();
    return $middleware->process($request, $this);
}

这其中$this->middleware变量为核心,我们可以打印一下这个变量,我们在中间件中必须定义$response = $handler->handle($request);实际上就是去执行上面的handle方法,当前变量$this会一直传递存在于作用域上,然后就是配合array_shift()函数一步步执行中间件中我们定义的process()函数

^ array:5 [0 => Psr\Http\Server\MiddlewareInterface@anonymous {#22}
  1 => App\middleware\TestMiddleware {#33}
  2 => League\Route\Route {#40 ▼
    #handler: "App\Controller\TestController::index"
    #group: null
    #method: "GET"
    #path: "/"
    #vars: []
    #middleware: array:1 []
    #host: null
    #name: null
    #scheme: null
    #port: null
    #strategy: League\Route\Strategy\ApplicationStrategy {#34}
  }
]

响应

处理响应使用了composer require laminas/laminas-httphandlerrunner这个配套的第三方包,上述的路由系统返回结果依赖于ResponseInterface这个接口,返回结果必须为继承ResponseInterface的对象,所以在添加路由闭包或者路由到控制器都必须返回该响应对象.

添加ResponseServiceProvider响应服务提供者,将response对象绑定到容器当中。

这里对返回响应做了一层封装,添加了一个全局函数,其中$data为响应内容,$status为响应状态码,这样就可以简化代码,方便完成响应

if (!function_exists('response')){
    //返回响应
    function response($data,$status = 200){
        app('response')->getBody()->write($data);
        return app('response')->withStatus($status);
    }
}

视图

这里使用了laravel的模版引擎,参考了V视图实现(Laravel Blade引擎)

通过引入composer require duncan3dc/blade

添加视图配置./config/view.php


<?php

return [

    // 模板缓存路径
    'cache_path' => FRAME_BASE_PATH . '/resource/views/cache',

    // 模板的根目录
    'view_path' => FRAME_BASE_PATH . '/resource/views/'
];

添加视图核心类./core/View.php


use duncan3dc\Laravel\BladeInstance;

class View
{
    protected $template;

    public function __construct()
    {
        // 设置视图路径和缓存路径
        $this->template = new BladeInstance(config('view.view_path'), config('view.cache_path'));
    }

    // 传递路径和参数
    public function render($path, $params = [])
    {
        return $this->template->render($path, $params);
    }

}

然后再添加ViewServiceProvider视图服务提供者,将view对象绑定到容器当中

由于之前使用的路由系统,我们视图返回的内容必须ResponseInterface对象,所以这里对view返回结果进行封装,这样视图的使用就跟laravel基本保存一致啦。

if (!function_exists('view')){
    //渲染视图
    function view($path,$params = []){
        $view = app('view')->render($path,$params);
        app('response')->getBody()->write($view);
        return app('response');
    }
}

数据库

通过引入composer require topthink/think-orm第三方包操作数据库和模型,详细第三方包信息可查看ThinkORM开发指南

添加配置文件./config/database.php,里面包含数据库的基本配置

再添加DBServiceProvider数据库服务提供者,这里的register()只需要初始化Db类配置信息

public function register()
{
    //数据库配置信息设置(全局有效)
    Db::setConfig(config('database'));
}

查询构造器

这里直接引用了第三方包,所以研究研究解读一下源码

我这里根据下面这行代码解读内部的使用

Db::table('test')->where('id','=',11)->select()

在查看源码./vendor/topthink/think-orm/src/facade/Db.php文件中,通过Db::table()这种静态方法调用类会触发__callStatic()魔术方法,方法会调用think\DbManager类下的方法并将参数传入

protected static function createFacade(bool $newInstance = false)
{
    $class = static::getFacadeClass() ?: 'think\DbManager';

    if (static::$alwaysNewInstance) {
        $newInstance = true;
    }

    if ($newInstance) {
        return new $class();
    }

    if (!self::$instance) {
        self::$instance = new $class();
    }

    return self::$instance;

}

// 调用实际类的方法
public static function __callStatic($method, $params)
{
    return call_user_func_array([static::createFacade(), $method], $params);
}

再查看./vendor/topthink/think-orm/src/DbManager.php文件,触发了魔术方法__call调用了$this->connect()方法,其中connect()方法返回了数据库连接类的实例

 /**
 * 创建/切换数据库连接查询
 * @access public
 * @param string|null $name  连接配置标识
 * @param bool        $force 强制重新连接
 * @return ConnectionInterface
 */
public function connect(string $name = null, bool $force = false)
{
    return $this->instance($name, $force);
}


public function __call($method, $args)
{
    return call_user_func_array([$this->connect(), $method], $args);
}

再来看看createConnection方法,这个方法相当于是一个工厂函数,通过配置中传入的type,实例化返回对应的连接类,连接类位于./vendor/topthink/think-orm/src/db/connector目录下,目前支持mongodb,mysql,oracle,sqlite等多种数据库类型,所以上面的魔术方法__call实际上是调用了./vendor/topthink/think-orm/src/db/connector/Mysql.php中的方法

/**
 * 创建连接
 * @param $name
 * @return ConnectionInterface
 */
protected function createConnection(string $name): ConnectionInterface
{
    $config = $this->getConnectionConfig($name);

    $type = !empty($config['type']) ? $config['type'] : 'mysql';

    if (false !== strpos($type, '\\')) {
        $class = $type;
    } else {
        $class = '\\think\\db\\connector\\' . ucfirst($type);
    }

    /** @var ConnectionInterface $connection */
    $connection = new $class($config);
    $connection->setDb($this);

    if ($this->cache) {
        $connection->setCache($this->cache);
    }
    return $connection;
}

查看./vendor/topthink/think-orm/src/db/connector/Mysql.php代码发现其继承了./vendor/topthink/think-orm/src/db/PDOConnection.php,然后PDOConnection又继承了./vendor/topthink/think-orm/src/db/Connection.php数据库连接基础类,最终调用了Connection类中的方法,实际上调用业务逻辑实现是在./vendor/topthink/think-orm/src/db/BaseQuery.php这个数据查询基础类

/**
 * 指定表名开始查询
 * @param $table
 * @return BaseQuery
 */
public function table($table)
{
    return $this->newQuery()->table($table);
}

实际上查看构造器的流程都差不多像这样,通过调用call和callStatic()两个魔术方法,去调用其他实例

模型

在创建./app/Model目录,该目录存放我们定义的模型类,我这里创建一个测试模型类Test.php

namespace App\Model;


use think\Model;

class Test extends Model
{
    protected $table = 'test';
}

我们就可以通过Test::where('id','=',11)->select()操作数据库,等价于上面的Db::table('test')->where('id','=',11)->select()

下面继续分析一下源码,看看内部执行了怎样的操作

我们定义的模型都需要继承./vendor/topthink/think-orm/src/Model.php这个模型类,类中也同样定义了call和callStatic两个魔术方法,使模型可以同查询构造器一样,使用相同的函数方法去查询。

public function __call($method, $args)
{
    if (isset(static::$macro[static::class][$method])) {
        return call_user_func_array(static::$macro[static::class][$method]->bindTo($this, static::class), $args);
    }

    if ('withattr' == strtolower($method)) {
        return call_user_func_array([$this, 'withAttribute'], $args);
    }

    return call_user_func_array([$this->db(), $method], $args);
}

public static function __callStatic($method, $args)
{
    if (isset(static::$macro[static::class][$method])) {
        return call_user_func_array(static::$macro[static::class][$method]->bindTo(null, static::class), $args);
    }

    $model = new static();

    return call_user_func_array([$model->db(), $method], $args);
}

其中db()函数为模型的重点

/**
 * 获取当前模型的数据库查询对象
 * @access public
 * @param array $scope 设置不使用的全局查询范围
 * @return Query
 */
public function db($scope = []): Query
{
    //实例化./vendor/topthink/think-orm/src/DBManager.php类,在上面讲解查询构造器中,我们知道DBManager类为查询构造器的核心
    $query = self::$db->connect($this->connection)
        ->name($this->name . $this->suffix)
        ->pk($this->pk);

    //设置查询的表名,表名为我们在模型当中定义的$table变量
    if (!empty($this->table)) {
        $query->table($this->table . $this->suffix);
    }

    $query->model($this)
        ->json($this->json, $this->jsonAssoc)
        ->setFieldType(array_merge($this->schema, $this->jsonType));

    // 软删除
    if (property_exists($this, 'withTrashed') && !$this->withTrashed) {
        $this->withNoTrashed($query);
    }

    // 全局作用域
    if (is_array($scope)) {
        $globalScope = array_diff($this->globalScope, $scope);
        $query->scope($globalScope);
    }
    // 返回当前模型的数据库查询对象
    return $query;
}

我这里为模型添加一个全局作用域,分析一下模型的作用域是如何实现的,代码如下

use think\Model;

class Activity extends Model
{
    protected $table = 'activity';

    // 定义全局的查询范围
    protected $globalScope = ['status'];

    public function scopeStatus($query)
    {
        $query->where('status',1);
    }
}

在./vendor/topthink/think-orm/src/Model.php类中的db()方法,判断了是否有添加全局作用域,如果添加则调用scope()函数,这里全局作用域和局部作用域的实现都是经过这个scope函数

/**
 * 获取当前模型的数据库查询对象
 * @access public
 * @param array $scope 设置不使用的全局查询范围
 * @return Query
 */
public function db($scope = []): Query
{
    /** @var Query $query */
    $query = self::$db->connect($this->connection)
        ->name($this->name . $this->suffix)
        ->pk($this->pk);

    if (!empty($this->table)) {
        $query->table($this->table . $this->suffix);
    }
    $query->model($this)
        ->json($this->json, $this->jsonAssoc)
        ->setFieldType(array_merge($this->schema, $this->jsonType));

    // 软删除
    if (property_exists($this, 'withTrashed') && !$this->withTrashed) {
        $this->withNoTrashed($query);
    }
    // 全局作用域
    if (is_array($scope)) {
        $globalScope = array_diff($this->globalScope, $scope);
        $query->scope($globalScope);
    }
    // 返回当前模型的数据库查询对象
    return $query;
}

查看./vendor/topthink/think-orm/src/db/concern/ModelRelationQuery类的scope函数,这里就是模型作用域的主要实现函数,我们在定义作用域中返回的$query是./vendor/topthink/think-orm/src/db/Query类的实例,该实例为PDO数据查询类

/**
 * 添加查询范围
 * @access public
 * @param array|string|Closure $scope 查询范围定义
 * @param array                $args  参数
 * @return $this
 */
public function scope($scope, ...$args)
{
    // 查询范围的第一个参数始终是当前查询对象
    array_unshift($args, $this);

    if ($scope instanceof Closure) {
        call_user_func_array($scope, $args);
        return $this;
    }

    if (is_string($scope)) {
        $scope = explode(',', $scope);
    }
    if ($this->model) {
        // 检查模型类的查询范围方法
        foreach ($scope as $name) {
            //这里强制了作用域方法必须以scope开头,
            $method = 'scope' . trim($name);
            if (method_exists($this->model, $method)) {
                call_user_func_array([$this->model, $method], $args);
            }
        }
    }

    return $this;
}

再来看看模型关联,这里添加了一个商品模型./app/Model/Goods.php

数据库结构为下面这种形式

activity
    id - integer
    name - string

activity_goods
    id - integer
    activity_id - integer
    goods_id - integer

goods
    id - integer
    name - string 

在activity活动类型中定义关联关系,这里定义多对多的关联方式

public function goods()
{
    return $this->belongsToMany(Goods::class,'activity_goods','goods_id','activity_id');
}

在获得关联可使用Activity::where('id','=',60)->find()->goods()->select()Activity::with(['goods'])->where('id','=',60)->find()两种方式获取数据,现在通过源码分析一下,这里使用的belongsToMany实际上调用了./vendor/topthink/think-orm/src/model/concern/RelationShip.php中的belongsToMany方法

/**
 * BELONGS TO MANY 关联定义
 * @access public
 * @param  string $model      模型名
 * @param  string $middle     中间表/模型名
 * @param  string $foreignKey 关联外键
 * @param  string $localKey   当前模型关联键
 * @return BelongsToMany
 */
public function belongsToMany(string $model, string $middle = '', string $foreignKey = '', string $localKey = ''): BelongsToMany
{
    // 记录当前关联信息
    $model      = $this->parseModel($model);
    $name       = Str::snake(class_basename($model));
    $middle     = $middle ?: Str::snake($this->name) . '_' . $name;
    $foreignKey = $foreignKey ?: $name . '_id';
    $localKey   = $localKey ?: $this->getForeignKey($this->name);
    return new BelongsToMany($this, $model, $middle, $foreignKey, $localKey);
}

查看./vendor/topthink/think-orm/src/model/relation/BelongsToMany的构造方法,这里可以看到$this->query = (new $model)->db()已经可以知道已经实例化了数据库查询对象,剩余的过程就跟其他正常的模型一致,当然其他关联模型的原理也是类似

public function __construct(Model $parent, string $model, string $middle, string $foreignKey, string $localKey)
{
    $this->parent     = $parent;
    $this->model      = $model;
    $this->foreignKey = $foreignKey;
    $this->localKey   = $localKey;

    if (false !== strpos($middle, '\\')) {
        $this->pivotName = $middle;
        $this->middle    = class_basename($middle);
    } else {
        $this->middle = $middle;
    }

    $this->query = (new $model)->db();
    $this->pivot = $this->newPivot();
}

再来看看预加载的方式,这里实际上调用了./vendor/topthink/think-orm/src/db/concern/ModelRelationQuery中的with方法,实际上这里的with()只是将类中的$this->options设置为我们需求的预加载数组,实际调用还不在这里

/**
 * 关联预载入 In方式
 * @access public
 * @param array|string $with 关联方法名称
 * @return $this
 */
public function with($with)
{
    if (!empty($with)) {
        $this->options['with'] = (array) $with;
    }

    return $this;
}

在获取数据的find()和select()方法中,会进行判断,是否返回模型$this->resultToModel()这个方法中,其中会判断是否添加了预加载

public function find($data = null)
{
    if (!is_null($data)) {
        // AR模式分析主键条件
        $this->parsePkWhere($data);
    }

    if (empty($this->options['where']) && empty($this->options['order'])) {
        $result = [];
    } else {
        $result = $this->connection->find($this);
    }

    // 数据处理
    if (empty($result)) {
        return $this->resultToEmpty();
    }

    if (!empty($this->model)) {
        // 返回模型对象
        $this->resultToModel($result, $this->options);
    } else {
        $this->result($result);
    }

    return $result;
}

protected function resultToModel(array &$result, array $options = [], bool $resultSet = false, array $withRelationAttr = []): void
{
    ...
    // 预载入查询
    if (!$resultSet && !empty($options['with'])) {
        $result->eagerlyResult($result, $options['with'], $withRelationAttr, false, $options['with_cache'] ?? false);
    }
    ...
}

这里就是预加载的核心方法了,$relationResult = $this->$relation();代码执行了我们定义的关联方法,返回了BelongsToMany类实例,再调用其中的eagerlyResult()方法设置了$this->relation变量

/**
 * 预载入关联查询 返回模型对象
 * @access public
 * @param  Model $result    数据对象
 * @param  array $relations 关联
 * @param  array $withRelationAttr 关联获取器
 * @param  bool  $join      是否为JOIN方式
 * @param  mixed $cache     关联缓存
 * @return void
 */
public function eagerlyResult(Model $result, array $relations, array $withRelationAttr = [], bool $join = false, $cache = false): void
{
    foreach ($relations as $key => $relation) {
        $subRelation = [];
        $closure     = null;

        if ($relation instanceof Closure) {
            $closure  = $relation;
            $relation = $key;
        }

        if (is_array($relation)) {
            $subRelation = $relation;
            $relation    = $key;
        } elseif (strpos($relation, '.')) {
            [$relation, $subRelation] = explode('.', $relation, 2);

            $subRelation = [$subRelation];
        }

        $relationName = $relation;
        $relation     = Str::camel($relation);
        $relationResult = $this->$relation();
        if (isset($withRelationAttr[$relationName])) {
            $relationResult->withAttr($withRelationAttr[$relationName]);
        }

        if (is_scalar($cache)) {
            $relationCache = [$cache];
        } else {
            $relationCache = $cache[$relationName] ?? [];
        }

        $relationResult->eagerlyResult($result, $relationName, $subRelation, $closure, $relationCache, $join);
    }
}

日志

这里写了一个比较简单的日志实现类

添加日志配置./config/log.php

return [
    //默认渠道
    'default' => 'single',

    'channel' => [

        'single' => [
            //日志驱动为文件
            'driver' => 'file',
            'path' => FRAME_BASE_PATH.'/storage/logs/ollie.log'
        ],
        'daily' => [
            'driver' => 'file',
            'path' => FRAME_BASE_PATH.'/storage/logs/'.date('Y-m-d').'.log'
        ]
    ]

];

这里需要为日志创建相关的目录./storage/logs目录

添加日志核心类./core/Log.php,代码如下,实现比较简单,就是向文件写入信息。并添加了固定渠道和根据日期写入渠道

use core\logDriver\file;

class Log
{
    //日志渠道
    protected $channel;
    //日志驱动
    protected $driver;
    //路径
    protected $path;
    //当前日志实体类
    protected $instance;

    public function __construct()
    {
        $this->channel = config('log.default');
        $this->driver = config('log.channel.'.$this->channel.'.driver');
        $this->path = config('log.channel.'.$this->channel.'.path');
        $this->getDriverInstance();
    }

    //重新定义日志渠道
    public function channel($name = null)
    {
        if (!$name){
            $this->channel = config('log.default');
            $this->driver = config('log.channel.'.$this->channel.'.driver');
            $this->path = config('log.channel.'.$this->channel.'.path');
        }else{
            $this->channel = $name;
            $this->driver = config('log.channel.'.$this->channel.'.driver');
            $this->path = config('log.channel.'.$this->channel.'.path');
        }
        $this->getDriverInstance();
        return $this;
    }

    //获取日志驱动实体类
    public function getDriverInstance()
    {
        if ($this->driver == 'file'){
            $this->instance = new file();
        }
    }

    public function info($message)
    {
        if ($this->driver == 'file'){
            $this->instance->info($message,$this->path);
        }
    }
}

异常处理

经过上面的流程处理,可以把./index.php中的代码简化成如下几行代码,这样看得也比较爽,现在引入异常处理中心

define('FRAME_BASE_PATH', __DIR__); // 框架目录
//引入自动加载
require __DIR__.'/vendor/autoload.php';
//引入容器类文件
require_once __DIR__.'/core/Container.php';
//实例化容器(包括初始化服务)
$container = app();
//返回响应
$response = app('router')->dispatch(app('request'));
//将响应返回客户端
(new Laminas\HttpHandlerRunner\Emitter\SapiEmitter)->emit($response);

首先创建./app/Exceptions异常处理目录,并创建ExceptionHub.php和ExceptionInterface.php两个文件,两个文件功能为异常处理中心类和异常处理接口类,
文件内容如下所示

class ExceptionHub implements ExceptionInterface
{
    //处理异常类
    protected $handleException;

    //错误异常中心处理
    public function handle($exception)
    {
        $this->createExceptions(get_class($exception));
        if (!$this->handleException){
            //未知异常
            $this->notFoundExceptions();
            exit();
        }
        //异常处理
        $this->handleException->handle($exception);
    }

    //工厂函数,创建处理异常类
    public function createExceptions($className)
    {
        $explode = explode('\\',$className);
        $exceptionName = last($explode);
        $handleExceptionName = 'App\Exceptions\\'.$exceptionName;
        if (class_exists($handleExceptionName)){
            $this->handleException = new $handleExceptionName();
        }
    }

    public function notFoundExceptions()
    {
        echo '未知异常';
    }
}

interface ExceptionInterface
{
    //错误处理
    public function handle($exception);
}

创建ExceptionServiceProvider异常处理服务提供者,绑定添加exception服务

可以把./index.php文件优化成如下代码,对异常进行捕获,传递给ExceptionHub类进行处理

define('FRAME_BASE_PATH', __DIR__); // 框架目录

require __DIR__.'/vendor/autoload.php';

require_once __DIR__.'/core/Container.php';
//实例化容器(包括初始化服务)
$container = app();
try {
    //返回响应
    $response = app('router')->dispatch(app('request'));
    //将响应返回客户端
    (new Laminas\HttpHandlerRunner\Emitter\SapiEmitter)->emit($response);
}catch (\Exception $exception){
    app('exception')->handle($exception);
}

这里以路由系统中的路由未匹配成功和请求方法不允许错误为例子

创建./app/Exceptions/MethodNotAllowedException.php文件和./app/Exceptions/NotFoundException.php文件,我们将两个异常进行专门的处理,若路由未匹配成功,则会输出路由未匹配成功;若请求方法不允许,则会输出请求方法未被允许。当然这里只是举例说明异常处理的情况,我们在编码过程中,可以主动抛出错误,并在专门创建的异常类中处理异常

class MethodNotAllowedException implements ExceptionInterface
{

    public function handle($exception)
    {
        echo '请求方法未被允许';
    }
}

class NotFoundException implements ExceptionInterface
{

    public function handle($exception)
    {
        echo '路由未匹配成功';
    }
}
本作品采用《CC 协议》,转载必须注明作者和本文链接
本帖由系统于 7个月前 自动加精
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
讨论数量: 6

我下载看了下 非常不错

按照自己的想法来改编

和那个手摸手框架比起来 你这个更完善 更好

(启动你这个框架的时候 1. 需要创建.env 2. /about无法访问)

7个月前 评论
oliver-l (楼主) 7个月前
lianglunzhong 6个月前
xiaobei

file 这里应该是 ===

5个月前 评论
oliver-l (楼主) 5个月前

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!