开发一个基于slim的mvc框架

开发一个基于slim的MVC框架,在slim框架的基础上进行代码组织结构方面的提升,希望该框架能很好地实现项目代码结构的分层,能够支持中型应用的实现。

slim框架是什么?

slim框架是什么?我们可以参考SlimPHP中文网 - Slim 框架的描述,此处搬运一下

Slim是一款 PHP 微框架,可以帮助你快速编写简单但功能强大的 web 应用和 API 。在它的核心,Slim 是一个调度程序,它接收一个 HTTP 请求,调用一个适当的回调例程,然后返回一个 HTTP 响应。就这样简单。

是的,就是接受请求、回调、返回响应。slim框架是一个moderm php框架,由文档可知,文档主要描述的就是请求、响应、路由三块内容,并且框架的概念一章,描述了slim实现的主要思想

  1. 请求与响应对象支持PSR-7
  2. 用于某些场景的处理——同心圆式的中间件调用
  3. 解耦——服务容器的实现

但是目前slim框架已经升级至4.0版本,本文依旧使用的是slim 3.0版本。

Hello, World——slim框架的基础使用

安装

创建目录,按照文档,使用composer安装

composer require slim/slim "^3.0"

然后在根目录下创建一个index.php文件,引入composer的提供的自动加载器即可使用slim(与日常使用的其他composer组件一样),然后安装国际惯例我们尝试打印一个Hello, world

// index.php
<?php
require 'vendor/autoload.php';

// 创建并配置 Slim app
$app = new \Slim\App;

$app->get('/', function ($request, $response, $args) {
    return $response->write("Hello, world");
});

// 运行 app
$app->run();

在浏览器中访问,即可看到打印Hello, world

由此,我们看到slim框架的使用,可以在一个.php文件中完成,仅业务代码其执行流程为:实例化slim -> 请求与响应 -> 实例运行

更细化一点描述,该段index.php代码中,起主要作用的代码段为

$app->get('/', function ($request, $response, $args) {
    return $response->write("Hello, world");
});

对于这段代码,意思是

  1. $app->get('/', function(){}),定义一个路由’/‘,定义匿名函数作为路由回调方法

  2. $request,把PSR 7 请求对象作为路由回调的第一个参数注入到你的 Slim 应用程序的路由中,

  3. $response,PSR 7 响应对象作为路由回调的第二个参数注入到 Slim 应用程序的路由中

  4. $args,接收路由参数

  5. $response->wiite(),返回内容写入到 PSR 7 响应对象

更详细的信息建议查看文档中请求与响应章节。

为什么要基于slim框架?

  1. slim是一个微框架,也就是说代码体积很小,并不是像ThinkPHP,或者larvael框架一般提供十分完整的操作逻辑。就此或许能描述为:slim框架设计的本意就是——人们不一定需要大型框架的全部功能,开发者可以自行选择自己所需要的。
  2. 支持psr7的请求与响应对象,支持容器,支持中间件——modern php framework,也就是说slim虽然小,但是足够应对基础场景。

就此,明确的是,并非本人独立开发了一个php框架(笑,而是本框架基于slim框架,然后对于官方文档中代码组织结构的不明晰,故而对该部分进行更为MVC的代码组织结构上的修改。主要实现内容,slim框架中已有很好的实现,另一部分可以说本文是在slim框架下开发的一个应用而已。

开发目标——怎么才能算一个基础MVC框架呢?

MVC主要是一种代码组织结构,而对于如 Thinkphp框架,每个模块中controller, model, validate层次分明,对于中大型项目而言,不可能把全部代码放在同一个php文件中,就此开发——因为这样的项目开发逻辑与维护性都极差。

对于一般MVC框架的加载流程,个人理解为:

  1. 入口文件,定义常量
  2. 加载异常处理类(未完成)
  3. 加载框架核心类,加载自动加载类
  4. 读取配置文件,获取应用程序实例
  5. 注册容器服务,中间件,路由
  6. 启动应用程序
  7. 路由回调

故我们的目标是什么呢?实现目标是:

  1. 实现单一入口
  2. 项目的代码结构分层(Action - Model)
  3. 实现遵从PSR-0规范的自动加载
  4. 实现配置文件独立
  5. 中间件与容器独立配置/编写

但是又由于个人更倾向于前后端分离的开发模式,故本文中的MVC的View实际上并不存在hhh,所以或许对于本文的MVC,可以视作Action-Model。

所以,结合我理解的一般MVC框架的加载流程,框架加载流程图如下图所示:

加载流程

项目目录结构

基本的目录结构如下所示,

.
├── App				# 应用程序
├── Core				# 框架核心类
├── composer.json		
├── composer.lock
├── public				# 入口文件
└── vendor				

框架主要加载流程与具体实现

  1. public/index.php 入口文件,定义常量:应用程序路径,框架核心类路径,配置文件路径
  2. 引入Core/Init.php,进行核心类的初始化工作
  3. 在Core/Init.php中,注册composer的自动加载器,注册框架自定义自动加载器
  4. Core/Config.php定义配置文件类,并提供给Init.php
  5. Core/start.php,提供实例化slim应用操作,返回app实例
  6. 在public/index.php中引入public/start.php框架执行类
  7. 启动session或者其他常规设置
  8. 注册检索当前路由中间件,以便获取路由参数信息
  9. 注册slim容器
  10. 注册slim路由
  11. 运行app实例

其实难度而言,不是很难,重点是框架代码的组织结构,根据文档一步一步,把相关内容都独立处理而不是放在同一个文件中。从public/index.php开始,一行一行下去就能清晰理解了。

如何使用?

本节关心的内容时,如何使用,也就是如何进行curd操作。一般步骤:

  1. 路由定义
  2. 编写controller,继承Core/Action基类(本框架中Action为控制器类,与平常使用的Controller有些区别,但也只是单词使用不同而已,实质内容与作用相同)
  3. 编写model,基础Core/Model基类
  4. 对于redis,或者monolog此类组件,可以在Dependencies.php容器类中定义,然后可在全局使用。
  5. 中间件定义与使用

容器定义——以monolog为例

引入monolog

composer require monolog/monolog

创建Log目录,以及更改权限

在项目根目录下创建Log文件夹,更改权限为777,并在入口文件中定义Log日志目录常量

mkdir Log
chmod -R 777 Log

# public/index.php
define("LOG_PATH", __DIR__ . "/../Log");

在容器中定义

// Dependencies.php
$container['logger'] = function($c){
    // 
    $logger = new \Monolog\Logger('my_logger');
    // 日志按日记录
    $log_name = LOG_PATH . DIRECTORY_SEPARATOR . date('Ymd', time()) . '.log';
    $file_handler = new \Monolog\Handler\StreamHandler($log_name);
    $logger->pushHandler($file_handler);
    return $logger;
};

在Action中使用

// Test.php
<?php
namespace App\Action;

use Core\Action;
use App\Model\Test as TestModel;

class Test extends Action
{
    /**
     * 测试monolog 日志记录
     */
    public function log()
    {
        // 在容器获取logger实例
        $this->_container->get('logger')->addInfo('test logger');
        echo 'logger runing';
    }    
}

结果

在项目Log目录下,出现当前日期命名的log日志文件,查看.log文件内容,可见是Action定义的addInfo内容“test logger”

log

中间件使用

Slim文档的描述:

在你的 Slim 应用之前(before)之后(after) 运行代码来处理你认为合适的请求和响应对象。这就叫做*中间件*。

或另一个角度参照 ThinkPHP5.1 框架中对中间件的描述,引入AOP(面向切片编程)作为对比,在程序运行的某个片段,执行代码处理你想要进行的操作。slim文档中对中间件的调用顺序用一个同心圆来描述。本节将展示中间件的定义与使用,并展示中间件调用的顺序。存在两种中间件添加方式,应用程序中间件和路由中间件,两者的区别,简单概述就是“应用程序中间件”被每个传入(incoming) HTTP 请求调用,也就是说每个请求都会调用该中间件;而路由中间件,*只有*在当前 HTTP 请求的方法和 URI 都与中间件所在路由相匹配时才会被调用,也就是说该中间件是为指定路由而添加,当然也能为路由组指定。

详细定义与描述,可阅读slim文档中对中间件的描述,这里功利一点只描述在本修改后框架中如何使用。

中间件的定义与实现

中间件是一个接收三个参数的可回调(callable)对象,三个参数是:请求对象、响应对象以及下一层中间件。中间件必须返回一个 \Psr\Http\Message\ResponseInterface 的实例,

  1. 闭包实现,
   # 定义实例
   function($request, $response, $next){
       ...
       $response = $next($request, $response);
       ...
       return $response;
   }
  1. 可调用类__invoke()的实现
   class ExampleMiddleware
   {
       public function __invoke($request, $response, $next)
       {
           ...
           $response = $next($request, $response);
           ...
           return $response;
       }
   }

可见,这里的重点无论闭包实现还是__invoke()可调用类的实现,都需要这三个参数,又参照中间件的定义,在程序运行的某个点,执行处理代码——或者举个例子“用户权限验证”来说,在程序实例app执行代码之前,需要判断用户的身份,这就是那个点,因为肯定是先验证在执行操作,比如有个路由定义(只是举个例子)

$app->post('/{user_name}/edit_password', 'user\manager\edit_password');

在修改用户密码之前,这个时刻,程序必须先验证用户的身份(如session,JWT等等),此时我们可以为此“操作”,添加一个中间件,而不是在控制器中编写此方面验证的代码。这也是一种解耦吧,让控制器的操作更加纯粹。另一方面,可以实现代码的抽象,比如假设有很多用户操作都需要验证权限,总不能每个控制器都复制那么一段权限验证的代码吧。

然后,需要一个

中间件的调用与执行顺序

本框架中一般使用__invoke()可调用类的方式。在App目录下新建Middleware文件夹,中间件类就存放在此。

# SimpleMiddleware.php
<?php
namespace App\Middleware;

class SimpleMiddleware
{
    public function __invoke($request, $response, $next)
    {
        $response->getBody()->write("---Eample---");
        $response = $next($request, $response);
        return $response;
    }
}

在控制器中定义

Action 控制器

Model 模型类

在本框架中使用Illuminate Database (Laravel 数据库操作)

按照slim文档 - 在slim中使用Eloquent,总感觉很奇怪,按我所理解的文档中使用Eloquent的步骤,

  1. 编写db config
  2. 容器中实例化\Illuminate\Database\Capsule\Manager,进行连接管理

我所感到的奇怪就是下面的步骤(感谢陈先生的指导)

  1. 使用容器注册表模型

使用容器注册表模型

  1. 把查询数据库的功能放到了controller里面

这两步,个人理解就是在容器中,为每一个需要进行数据库curd操作的控制器进行注册,然后在controller中进行操作?这也太不MVC了吧,可能我是掉进了ThinkPHP的惯性思维里面?而在laravel中Eloquent文档的描述,模型定义的方式是使用use Illuminate\Database\Eloquent\Model;我觉得后者更直接点吧。

本框架中的实现步骤

虽然感觉实现得有点丑陋

连接参数

// 数据库配置
'db' => [
    'driver' => 'mysql',
    'host' => '127.0.0.1',
    'database' => 'test',
    'username' => 'root',
    'password' => '123456',
    'charset' => 'utf8',
    'collation' => 'utf8_unicode_ci',
    'prefix' => '',
],

定义基类 \Core\Model.php

该类的工作是:进行连接管理,又因为php单继承的原因,在本类中继承Illuminate\Database\Eloquent\Model

<?php
/**
 * 模型类基类
 */
namespace Core;

use Illuminate\Database\Eloquent\Model as EloquentModel;

class Model extends EloquentModel
{
    public function __construct()
    {
        $capsule = new \Illuminate\Database\Capsule\Manager;
        // 获取配置,并进行建立连接
        $capsule->addConnection(\Core\Config::get('settings')['db']);
        $capsule->setAsGlobal();
        $capsule->bootEloquent();
    }
}

模型类定义

继承Model基类,定义需连接的数据表或其他(参见Eloquent文档)

# test.php
<?php
namespace App\Model;

use Core\Model;

class Test extends Model
{
    # 连接测试数据表
    protected $table = 'country';   
}

控制器中使用

$testModel = new TestModel();
$res = $testModel->get();
return $this->success(200, 'success', $res);

结果

{
  "code": 200,
  "msg": "success",
  "data": [
    {
      "id": 1,
      "name": "中国"
    },
    {
      "id": 2,
      "name": "美国"
    },
    {
      "id": 3,
      "name": "法国"
    }
  ]
}

题外话,一个小有意思的bug

aaa

google一下,发现对这个问题在gtithub的讨论很有意思,这个问题大概是:缺失"doctrine/dbal": "~2.3"依赖,而问题的产生是因为更新之后,composer require illuminate/database不再默认安装该依赖。

问题解决:

  1. composer.json 添加 "doctrine/dbal": "~2.3"
  2. composer update

而我觉得有意思的点在于,composer拯救依赖,而依赖的改动会引发很多问题。就如讨论而已,这个1.多M大小的依赖,是否应该提供默认包含。

另外让我感受到,这个真实世界——离开ThinkPHP之后,一切都变得很奇怪与奇妙(褒义)。同时对ThinkPHP的感情,有点感激——在于真的易于上手,而又有点抱怨——在于仿佛被局限了,看不到很多很多东西。

基础使用

框架限制与未实行功能

目前实现的一些不足:

  1. 仅支持单应用入口

也因为单应用入口,故容器类,配置类,路由类,均为该应用实例服务(或许下一步应该按照其余框架一般,定义几个层次的配置文件实现)

  1. 自定义\Core\Config 配置管理类,稍显多余,因为slim中已提供相关配置管理

未实现功能:

  1. 自定义错误处理
  2. log 日志记录
  3. phpunit单元测试实现

总结与参考

在Laravel外独立使用Eloquent