PHP現代化框架探祕

oliver-l發表於2020-12-03

其實我是來交作業的,之前看著這位老哥寫的文章[手摸手帶你建立php現代化框架] 教程終於寫完了,也跟著完成了相關的教程,所以就想著自己也嘗試寫一下框架的流程和構建。專案github地址lxzzze/ollie_frame.如果需要可以自行下載閱讀研究程式碼。後續可能會在此基礎上寫一個簡單的專案再修改修改。當然如果有什麼錯誤的地方,也歡迎指出,一起探討學習。

通過一步步搭建框架的每個部分,瞭解框架內部基本的實現原理思路,嘗試編寫相關程式碼去實現功能,或者引用別人寫好的第三方包,檢視其原始碼瞭解實現原理,並拼裝到框架當中,下面開始一步步介紹框架的各個組成。以下講解說明的內容不會貼所有的程式碼,只會將一些比較重要的程式碼塊拿出來說明。如果需要可以看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 協議》,轉載必須註明作者和本文連結

相關文章