为何咱们要去构建一个本身的 PHP 框架?可能绝大多数的人都会说“市面上已经那么多的框架了,还造什么轮子?”。个人观点“造轮子不是目的,造轮子的过程当中汲取到知识才是目的”。php
那怎样才能构建一个本身的 PHP 框架呢?大体流程以下:css
入口文件 ----> 注册自加载函数 ----> 注册错误(和异常)处理函数 ----> 加载配置文件 ----> 请求 ----> 路由 ---->(控制器 <----> 数据模型) ----> 响应 ----> json ----> 视图渲染数据
除此以外咱们还须要单元测试、nosql 支持、接口文档支持、一些辅助脚本等。最终个人框架目录以下:html
app [PHP 应用目录] ├── demo [模块目录] │ ├── controllers [控制器目录] │ │ └── Index.php [默认控制器文件,输出 json 数据] │ ├── logics [逻辑层,主要写业务逻辑的地方] │ │ ├── exceptions [异常目录] │ │ ├── gateway [一个逻辑层实现的 gateway 演示] │ │ ├── tools [工具类目录] │ │ └── UserDefinedCase.php [注册框架加载到路由前的处理用例] │ └── models [数据模型目录] │ └── TestTable.php [演示模型文件,定义一一对应的数据模型] ├── config [配置目录] │ ├── demo [模块配置目录] │ │ ├── config.php [模块自定义配置] │ │ └── route.php [模块自定义路由] │ ├── common.php [公共配置] │ ├── database.php [数据库配置] │ └── nosql.php [nosql 配置] docs [接口文档目录] ├── apib [Api Blueprint] │ └── demo.apib [接口文档示例文件] ├── swagger [swagger] framework [Easy PHP 核心框架目录] ├── exceptions [异常目录] │ ├── CoreHttpException.php[核心 http 异常] ├── handles [框架运行时挂载处理机制类目录] │ ├── Handle.php [处理机制接口] │ ├── ErrorHandle.php [错误处理机制类] │ ├── ExceptionHandle.php [未捕获异常处理机制类] │ ├── ConfigHandle.php [配置文件处理机制类] │ ├── NosqlHandle.php [nosql 处理机制类] │ ├── LogHandle.php [log 机制类] │ ├── UserDefinedHandle.php[用户自定义处理机制类] │ └── RouterHandle.php [路由处理机制类] ├── orm [对象关系模型] │ ├── Interpreter.php [sql 解析器] │ ├── DB.php [数据库操做类] │ ├── Model.php [数据模型基类] │ └── db [数据库类目录] │ └── Mysql.php [mysql 实体类] ├── nosql [nosql 类目录] │ ├── Memcahed.php [Memcahed 类文件] │ ├── MongoDB.php [MongoDB 类文件] │ └── Redis.php [Redis 类文件] ├── App.php [框架类] ├── Container.php [服务容器] ├── Helper.php [框架助手类] ├── Load.php [自加载类] ├── Request.php [请求类] ├── Response.php [响应类] ├── run.php [框架应用启用脚本] frontend [前端源码和资源目录] ├── src [资源目录] │ ├── components [vue 组件目录] │ ├── views [vue 视图目录] │ ├── images [图片] │ ├── ... ├── app.js [根 js] ├── app.vue [根组件] ├── index.template.html [前端入口文件模板] ├── store.js [vuex store 文件] public [公共资源目录,暴露到万维网] ├── dist [前端 build 以后的资源目录,build 生成的目录,不是发布分支忽略该目录] │ └── ... ├── index.html [前端入口文件,build 生成的文件,不是发布分支忽略该文件] ├── index.php [后端入口文件] runtime [临时目录] ├── logs [日志目录] ├── build [php 打包生成 phar 文件目录] tests [单元测试目录] ├── demo [模块名称] │ └── DemoTest.php [测试演示] ├── TestCase.php [测试用例] vendor [composer 目录] .git-hooks [git 钩子目录] ├── pre-commit [git pre-commit 预 commit 钩子示例文件] ├── commit-msg [git commit-msg 示例文件] .babelrc [babel 配置文件] .env [环境变量文件] .gitignore [git 忽略文件配置] build [php 打包脚本] cli [框架 cli 模式运行脚本] LICENSE [lincese 文件] logo.png [框架 logo 图片] composer.json [composer 配置文件] composer.lock [composer lock 文件] package.json [前端依赖配置文件] phpunit.xml [phpunit 配置文件] README-CN.md [中文版 readme 文件] README.md [readme 文件] webpack.config.js [webpack 配置文件] yarn.lock [yarn lock 文件]
定义一个统一的入口文件,对外提供统一的访问文件。对外隐藏了内部的复杂性,相似企业服务总线的思想。前端
// 载入框架运行文件 require('../framework/run.php');
使用 spl_autoload_register 函数注册自加载函数到__autoload 队列中,配合使用命名空间,当使用一个类的时候能够自动载入(require)类文件。注册完成自加载逻辑后,咱们就可使用 use 和配合命名空间申明对某个类文件的依赖。mysql
[file: framework/Load.php]webpack
脚本运行期间:laravel
经过函数 set_error_handler 注册用户自定义错误处理方法,可是 set_error_handler 不能处理如下级别错误,E_ERROR、E_PARSE、E_CORE_ERROR、E_CORE_WARNING、E_COMPILE_ERROR、E_COMPILE_WARNING,和在 调用 set_error_handler() 函数所在文件中产生的大多数 E_STRICT。因此咱们须要使用 register_shutdown_function 配合 error_get_last 获取脚本终止执行的最后错误,目的是对于不一样错误级别和致命错误进行自定义处理,例如返回友好的提示的错误信息。git
[file: framework/hanles/ErrorHandle.php]github
经过函数 set_exception_handler 注册未捕获异常处理方法,目的捕获未捕获的异常,例如返回友好的提示和异常信息。
[file: framework/hanles/ExceptionHandle.php]
加载框架自定义和用户自定义的配置文件。
[file: framework/hanles/ConfigHandle.php]
框架中全部的异常输出和控制器输出都是 json 格式,由于我认为在先后端彻底分离的今天,这是很友善的,目前咱们不须要再去考虑别的东西。
[file: framework/Response.php]
经过用户访问的 url 信息,经过路由规则执行目标控制器类的的成员方法。我在这里把路由大体分红了四类:
传统路由
domain/index.php?module=Demo&contoller=Index&action=test&username=test
pathinfo 路由
domain/demo/index/modelExample
用户自定义路由
// 定义在 config/moduleName/route.php 文件中,这个的 this 指向 RouterHandle 实例 $this->get('v1/user/info', function (Framework\App $app) { return 'Hello Get Router'; });
微单体路由
我在这里详细说下这里所谓的微单体路由,面向 SOA 和微服务架构大行其道的今天,有不少的团队都在向服务化迈进,可是服务化过程当中不少问题的复杂度都是指数级的增加,例如分布式的事务,服务部署,跨服务问题追踪等等。这致使对于小的团队从单体架构走向服务架构不免困难重重,因此有人提出来了微单体架构,按照个人理解就是在一个单体架构的 SOA 过程,咱们把微服务中的的各个服务仍是以模块的方式放在同一个单体中,好比:
app ├── UserService [用户服务模块] ├── ContentService [内容服务模块] ├── OrderService [订单服务模块] ├── CartService [购物车服务模块] ├── PayService [支付服务模块] ├── GoodsService [商品服务模块] └── CustomService [客服服务模块]
如上,咱们简单的在一个单体里构建了各个服务模块,可是这些模块怎么通讯呢?以下:
App::$app->get('demo/index/hello', [ 'user' => 'TIGERB' ]);
经过上面的方式咱们就能够松耦合的方式进行单体下各个模块的通讯和依赖了。与此同时,业务的发展是难以预估的,将来当咱们向 SOA 的架构迁移时,很简单,咱们只须要把以往的模块独立成各个项目,而后把 App 实例 get 方法的实现转变为 RPC 或者 REST 的策略便可,咱们能够经过配置文件去调整对应的策略或者把本身的,第三方的实现注册进去便可。
[file: framework/hanles/RouterHandle.php]
传统的 MVC 模式包含 model-view-controller 层,绝大多时候咱们会把业务逻辑写到 controller 层或 model 层,可是慢慢的咱们会发现代码难以阅读、维护、扩展,因此我在这里强制增长了一个 logics 层。至于,逻辑层里怎么写代码怎么,彻底由你本身定义,你能够在里面实现一个工具类,你也能够在里面再新建子文件夹并在里面构建你的业务逻辑代码,你甚至能够实现一个基于责任连模式的网关(我会提供具体的示例)。这样看来,咱们的最终结构是这样的:
logics 逻辑层
逻辑层实现网关示例:
咱们在 logics 层目录下增长了一个 gateway 目录,而后咱们就能够灵活的在这个目录下编写逻辑了。gateway 的结构以下:
gateway [Logics 层目录下 gateway 逻辑目录] ├── Check.php [接口] ├── CheckAppkey.php [检验 app key] ├── CheckArguments.php [校验必传参数] ├── CheckAuthority.php [校验访问权限] ├── CheckFrequent.php [校验访问频率] ├── CheckRouter.php [网关路由] ├── CheckSign.php [校验签名] └── Entrance.php [网关入口文件]
网关入口类主要负责网关的初始化,代码以下:
// 初始化一个:必传参数校验的 check $checkArguments = new CheckArguments(); // 初始化一个:app key check $checkAppkey = new CheckAppkey(); // 初始化一个:访问频次校验的 check $checkFrequent = new CheckFrequent(); // 初始化一个:签名校验的 check $checkSign = new CheckSign(); // 初始化一个:访问权限校验的 check $checkAuthority = new CheckAuthority(); // 初始化一个:网关路由规则 $checkRouter = new CheckRouter(); // 构成对象链 $checkArguments->setNext($checkAppkey) ->setNext($checkFrequent) ->setNext($checkSign) ->setNext($checkAuthority) ->setNext($checkRouter); // 启动网关 $checkArguments->start( APP::$container->getSingle('request') );
实现完成这个 gateway 以后,咱们如何在框架中去使用呢?在 logic 层目录中我提供了一个 user-defined 的实体类,咱们把 gateway 的入口类注册到 UserDefinedCase 这个类中,示例以下:
/** * 注册用户自定义执行的类 * * @var array */ private $map = [ // 演示 加载自定义网关 'App\Demo\Logics\Gateway\Entrance' ];
这样这个 gateway 就能够工做了。接着说说这个 UserDefinedCase 类,UserDefinedCase 会在框架加载到路由机制以前被执行,这样咱们就能够灵活的实现一些自定义的处理了。这个 gateway 只是个演示,你彻底能够天马行空的组织你的逻辑~
视图 View 去哪了?因为选择了彻底的先后端分离和 SPA(单页应用), 因此传统的视图层也所以去掉了,详细的介绍看下面。
源码目录
彻底的先后端分离,数据双向绑定,模块化等等的大势所趋。这里我把我本身开源的 vue 前端项目结构easy-vue移植到了这个项目里,做为视图层。咱们把前端的源码文件都放在 frontend 目录里,详细以下,你也能够本身定义:
frontend [前端源码和资源目录,这里存放咱们整个前端的源码文件] ├── src [资源目录] │ ├── components [编写咱们的前端组件] │ ├── views [组装咱们的视图] │ ├── images [图片] │ ├── ... ├── app.js [根 js] ├── app.vue [根组件] ├── index.template.html [前端入口文件模板] └── store.js [状态管理,这里只是个演示,你能够很灵活的编写文件和目录]
build 步骤
yarn install DOMAIN=http://你的域名 npm run dev
编译后
build 成功以后会生成 dist 目录和入口文件 index.html 在 public 目录中。非发布分支.gitignore 文件会忽略这些文件,发布分支去除忽略便可。
public [公共资源目录,暴露到万维网] ├── dist [前端 build 以后的资源目录,build 生成的目录,不是发布分支忽略该目录] │ └── ... ├── index.html [前端入口文件,build 生成的文件,不是发布分支忽略该文件]
数据库对象关系映射 ORM(Object Relation Map)是什么?按照我目前的理解:顾名思义是创建对象和抽象事物的关联关系,在数据库建模中 model 实体类其实就是具体的表,对表的操做其实就是对 model 实例的操做。可能绝大多数的人都要问“为何要这样作,直接 sql 语句操做很差吗?搞得这么麻烦!”,个人答案:直接 sql 语句固然能够,一切都是灵活的,可是从一个项目的可复用,可维护, 可扩展出发,采用 ORM 思想处理数据操做是理所固然的,想一想若是若干一段时间你看见代码里大段的难以阅读且无从复用的 sql 语句,你是什么样的心情。
市面上对于 ORM 的具体实现有 thinkphp 系列框架的 Active Record,yii 系列框架的 Active Record,laravel 系列框架的 Eloquent(听说是最优雅的),那咱们这里言简意赅就叫 ORM 了。接着为 ORM 建模,首先是 ORM 客户端实体 DB:经过配置文件初始化不一样的 db 策略,并封装了操做数据库的全部行为,最终咱们经过 DB 实体就能够直接操做数据库了,这里的 db 策略目前我只实现了 mysql(负责创建链接和 db 的底层操做)。接着咱们把 DB 实体的 sql 解析功能独立成一个可复用的 sql 解析器的 trait,具体做用:把对象的链式操做解析成具体的 sql 语句。最后,创建咱们的模型基类 model,model 直接继承 DB 便可。最后的结构以下:
├── orm [对象关系模型] │ ├── Interpreter.php [sql 解析器] │ ├── DB.php [数据库操做类] │ ├── Model.php [数据模型基类] │ └── db [数据库类目录] │ └── Mysql.php [mysql 实体类]
DB 类使用示例
/** * DB 操做示例 * * findAll * * @return void */ public function dbFindAllDemo() { $where = [ 'id' => ['>=', 2], ]; $instance = DB::table('user'); $res = $instance->where($where) ->orderBy('id asc') ->limit(5) ->findAll(['id','create_at']); $sql = $instance->sql; return $res; }
Model 类使用示例
// controller 代码 /** * model example * * @return mixed */ public function modelExample() { try { DB::beginTransaction(); $testTableModel = new TestTable(); // find one data $testTableModel->modelFindOneDemo(); // find all data $testTableModel->modelFindAllDemo(); // save data $testTableModel->modelSaveDemo(); // delete data $testTableModel->modelDeleteDemo(); // update data $testTableModel->modelUpdateDemo([ 'nickname' => 'easy-php' ]); // count data $testTableModel->modelCountDemo(); DB::commit(); return 'success'; } catch (Exception $e) { DB::rollBack(); return 'fail'; } } //TestTable model /** * Model 操做示例 * * findAll * * @return void */ public function modelFindAllDemo() { $where = [ 'id' => ['>=', 2], ]; $res = $this->where($where) ->orderBy('id asc') ->limit(5) ->findAll(['id','create_at']); $sql = $this->sql; return $res; }
什么是服务容器?
服务容器听起来很浮,按个人理解简单来讲就是提供一个第三方的实体,咱们把业务逻辑须要使用的类或实例注入到这个第三方实体类中,当须要获取类的实例时咱们直接经过这个第三方实体类获取。
服务容器的意义?
用设计模式来说:其实无论设计模式仍是实际编程的经验中,咱们都是强调“高内聚,松耦合”,咱们作到高内聚的结果就是每一个实体的做用都是极度专注,因此就产生了各个做用不一样的实体类。在组织一个逻辑功能时,这些细化的实体之间就会不一样程度的产生依赖关系,对于这些依赖咱们一般的作法以下:
class Demo { public function __construct() { // 类 demo 直接依赖 RelyClassName $instance = new RelyClassName(); } }
这样的写法没有什么逻辑上的问题,可是不符合设计模式的“最少知道原则”,由于之间产生了直接依赖,整个代码结构不够灵活是紧耦合的。因此咱们就提供了一个第三方的实体,把直接依赖转变为依赖于第三方,咱们获取依赖的实例直接经过第三方去完成以达到松耦合的目的,这里这个第三方充当的角色就相似系统架构中的“中间件”,都是协调依赖关系和去耦合的角色。最后,这里的第三方就是所谓的服务容器。
在实现了一个服务容器以后,我把 Request,Config 等实例都以单例的方式注入到了服务容器中,当咱们须要使用的时候从容器中获取便可,十分方便。使用以下:
// 注入单例 App::$container->setSingle('别名,方便获取', '对象 /闭包 /类名'); // 例,注入 Request 实例 App::$container->setSingle('request', function () { // 匿名函数懒加载 return new Request(); }); // 获取 Request 对象 App::$container->getSingle('request');
提供对 nosql 的支持,提供全局单例对象,借助咱们的服务容器咱们在框架启动的时候,经过配置文件的配置把须要的 nosql 实例注入到服务容器中。目前咱们支持 redis/memcahed/mongodb。
如何使用?以下,
// 获取 redis 对象 App::$container->getSingle('redis'); // 获取 memcahed 对象 App::$container->getSingle('memcahed'); // 获取 mongodb 对象 App::$container->getSingle('mongodb');
一般咱们写完一个接口后,接口文档是一个问题,咱们这里使用 Api Blueprint 协议完成对接口文档的书写和 mock(可用),同时咱们配合使用 Swagger 经过接口文档实现对接口的实时访问(目前未实现)。
Api Blueprint 接口描述协议选取的工具是 snowboard,具体使用说明以下:
接口文档生成说明
cd docs/apib ./snowboard html -i demo.apib -o demo.html -s open the website, http://localhost:8088/
接口 mock 使用说明
cd docs/apib ./snowboard mock -i demo.apib open the website, http://localhost:8087/demo/index/hello
基于 phpunit 的单元测试,写单元测试是个好的习惯。
如何使用?
tests 目录下编写测试文件,具体参考 tests/demo 目录下的 DemoTest 文件,而后运行:
vendor/bin/phpunit
测试断言示例:
/** * 演示测试 */ public function testDemo() { $this->assertEquals( 'Hello Easy PHP', // 执行 demo 模块 index 控制器 hello 操做,断言结果是否是等于 Hello Easy PHP App::$app->get('demo/index/hello') ); }
目的规范化咱们的项目代码和 commit 记录。
cli 脚本
以命令行的方式运行框架,具体见使用说明。
build 脚本
打包 PHP 项目脚本,打包整个项目到 runtime/build 目录,例如:
runtime/build/App.20170505085503.phar
<?php // 入口文件引入包文件便可 require('runtime/build/App.20170505085503.phar');
执行:
网站服务模式:
步骤 1: yarn install 步骤 2: DOMAIN=http://localhost:666 npm run demo 步骤 3: cd public 步骤 4: php -S localhost:666 访问网站: http://localhost:666/index.html 访问接口: http://localhost:666/Demo/Index/hello demo 以下:
客户端脚本模式:
php cli --method=<module.controller.action> --<arguments>=<value> ... 例如, php cli --method=demo.index.get --username=easy-php
获取帮助:
使用命令 php cli 或者 php cli --help