其實我是來交作業的,之前看著這位老哥寫的文章[手摸手帶你建立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配置
通過引用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 協議》,轉載必須註明作者和本文連結