4

我有一个依赖文件系统结构来分派请求的路由机制:

function Route($root) {
  $root = realpath($root) . '/';
  $segments = array_filter(explode('/',
    substr($_SERVER['PHP_SELF'], strlen($_SERVER['SCRIPT_NAME']))
  ), 'strlen');

  if ((count($segments) == 0) || (is_dir($root) === false)) {
    return true; // serve index
  }

  $controller = null;
  $segments = array_values($segments);

  while ((is_null($segment = array_shift($segments)) !== true)
    && (is_dir($root . $controller . $segment . '/'))) {
      $controller .= $segment . '/';
  }

  if ((is_file($controller = $root . $controller . $segment . '.php')) {
    $class = basename($controller . '.php');
    $method = array_shift($segments) ?: $_SERVER['REQUEST_METHOD'];

    require($controller);

    if (method_exists($class = new $class(), $method)) {
      return call_user_func_array(array($class, $method), $segments);
    }
  }

  throw new Exception('/' . implode('/', self::Segment()), 404); // serve 404
}

基本上,它尝试将尽可能多的 URL 段映射到目录,将以下段与实际控制器(.php具有相同名称的文件)匹配。如果提供了更多段,则第一个定义要调用的操作(回退到 HTTP 方法),其余部分作为操作参数。

问题是(取决于文件系统结构)存在一些歧义。考虑一下:

- /controllers
  - /admin
    - /company
      - /edit.php   (has get() & post() methods)
    - /company.php  (has get($id = null) method)

现在模棱两可-当我访问控制器时domain.tld/admin/company/edit/edit.php它会为请求提供服务(应该如此),但是domain.tld/admin/company/通过GETdomain.tld/admin/company/get/直接访问会引发 404 错误,因为该company段已映射到相应的目录,即使其余段在文件系统中没有映射。我该如何解决这个问题?最好不要在磁盘上投入太多精力。

SO中已经有很多关于这个问题的类似问题,我查看了其中一些,但我找不到提供可靠和有效解决方案的单一答案。

4

3 回答 3

3

对于像这样的关键内容,使用 PHPUnit 之类的测试框架编写测试非常重要。

像这里描述的那样安装它(你需要梨): https ://github.com/sebastianbergmann/phpunit/

我还使用虚拟文件系统,因此您的测试文件夹不会变得混乱:https ://github.com/mikey179/vfsStream/wiki/Install

我只是将您的 Route 函数放入一个名为Route.php. 在同一个目录中,我现在创建了一个test.php包含以下内容的文件:

<?php

require_once 'Route.php';

class RouteTest extends PHPUnit_Framework_TestCase {
}

要检查它是否一切正常,请打开命令行并执行以下操作:

$ cd path/to/directory
$ phpunit test.php
PHPUnit 3.7.13 by Sebastian Bergmann.

F

Time: 0 seconds, Memory: 1.50Mb

There was 1 failure:

1) Warning
No tests found in class "RouteTest".


FAILURES!
Tests: 1, Assertions: 0, Failures: 1.

如果出现这种情况,PHPUnit 已正确安装并且您已准备好编写测试。

为了使 Route 功能更好地可测试并且减少与服务器和文件系统的耦合,我对其进行了一些修改:

// new parameter $request instead of relying on server variables
function Route($root, $request_uri, $request_method) {
  // vfsStream doesn't support realpath(). This will do.
  $root .= '/';
  // replaced server variable with $request_uri
  $segments = array_filter(explode('/', $request_uri), 'strlen');

  if ((count($segments) == 0) || (is_dir($root) === false)) {
    return true; // serve index
  }

  $controller = null;
  $all_segments = array_values($segments);
  $segments = $all_segments;

  while ((is_null($segment = array_shift($segments)) !== true)
    && (is_dir($root . $controller . $segment . '/'))) {
      $controller .= $segment . '/';
  }

  if (is_file($controller = $root . $controller . $segment . '.php')) {
    $class = basename($controller . '.php');
    // replaced server variable with $request_method
    $method = array_shift($segments) ?: $request_method;

    require($controller);

    if (method_exists($class = new $class(), $method)) {
      return call_user_func_array(array($class, $method), $segments);
    }
  }
  // $all_segments variable instead of a call to self::
  throw new Exception('/' . implode('/', $all_segments), 404); // serve 404
}

如果请求索引路由,让我们添加一个测试来检查函数是否返回 true:

public function testIndexRoute() {
    $this->assertTrue(Route('.', '', 'get'));
    $this->assertTrue(Route('.', '/', 'get'));
}

因为您的测试类扩展PHPUnit_Framework_TestCase了您现在可以使用诸如$this->assertTrue 检查某个语句的计算结果是否为真之类的方法。让我们再次运行它:

$ phpunit test.php
PHPUnit 3.7.13 by Sebastian Bergmann.

.

Time: 0 seconds, Memory: 1.75Mb

OK (1 test, 2 assertions)

到这个测试通过了!让我们测试是否array_filter正确删除了空段:

public function testEmptySegments() {
    $this->assertTrue(Route('.', '//', 'get'));
    $this->assertTrue(Route('.', '//////////', 'get'));
}

$root如果路由的目录不存在,我们还可以测试是否请求索引路由。

public function testInexistentRoot() {
    $this->assertTrue(Route('./inexistent', '/', 'get'));
    $this->assertTrue(Route('./does-not-exist', '/some/random/route', 'get'));
}

为了测试比这更多的东西,我们现在需要包含带有方法的类的文件。因此,让我们在运行每个测试之前使用我们的虚拟文件系统来设置包含文件的目录结构。

require_once 'Route.php';
require_once 'vfsStream/vfsStream.php';

class RouteTest extends PHPUnit_Framework_TestCase {

    public function setUp() {
        // intiialize stuff before each test
    }

    public function tearDown() {
        // clean up ...
    }

PHPUnit 对这种事情有一些特殊的方法。该setUp方法在此测试类中的每个测试方法之前执行。并且tearDown执行了测试方法之后的方法。

现在我使用 vfsStream 创建一个目录结构。(如果您正在寻找一个教程来做到这一点:https ://github.com/mikey179/vfsStream/wiki是一个很好的资源)

    public function setUp() {
        $edit_php = <<<EDIT_PHP
<?php
class edit {
    public function get() {
        return __METHOD__ . "()";
    }
    public function post() {
        return __METHOD__ . "()";
    }
}
EDIT_PHP;

        $company_php = <<<COMPANY_PHP
<?php
class company {
    public function get(\$id = null) {
        return __METHOD__ . "(\$id)";
    }
}
COMPANY_PHP;

        $this->root = vfsStream::setup('controllers', null, Array(
            'admin' => Array(
                'company' => Array(
                    'edit.php' => $edit_php
                ),
                'company.php' => $company_php
            )
        ));
    }

    public function tearDown() {
        unset($this->root);
    }

vfsStream::setup()现在使用给定的文件结构和给定的文件内容创建一个虚拟目录。正如你所看到的,我让我的控制器将方法的名称和参数作为字符串返回。

现在我们可以向我们的测试套件添加更多测试:

public function testSimpleDirectMethodAccess() {
    $this->assertEquals("edit::get()", Route(vfsStream::url('controllers'), '/controllers/admin/company/edit/get', 'get'));
}

但是这次测试失败了:

$ phpunit test.php
PHPUnit 3.7.13 by Sebastian Bergmann.

...
Fatal error: Class 'edit.php.php' not found in C:\xampp\htdocs\r\Route.php on line 27

所以$class变量有问题。如果我们现在使用调试器(或一些)检查 Route 函数中的以下行echo

$class = basename($controller . '.php');

我们可以看到$controller变量包含正确的文件名,但为什么要.php附加一个?这似乎是一个打字错误。我认为应该是:

$class = basename($controller, '.php');

因为这会删除 .php 扩展名。我们得到了正确的 classname edit

现在让我们测试如果我们请求目录结构中不存在的随机路径是否引发异常。

/**
 * @expectedException Exception
 * @expectedMessage /random-route-to-the/void
 */
public function testForInexistentRoute() {
    Route(vfsStream::url('controllers'), '/random-route-to-the/void', 'get');
}

PHPUnit 自动读取此注释并检查Exception在执行此方法时是否抛出了异常类型,以及异常的消息是否被/random-route-to-the/void

这看起来很有效。让我们检查$request_method参数是否正常工作。

public function testMethodAccessByHTTPMethod() {
    $this->assertEquals("edit::get()", Route(vfsStream::url('controllers'), '/admin/company/edit', 'get'));
    $this->assertEquals("edit::post()", Route(vfsStream::url('controllers'), '/admin/company/edit', 'post'));
}

如果我们执行这个测试,我们会遇到另一个问题:

$ phpunit test.php
PHPUnit 3.7.13 by Sebastian Bergmann.

....
Fatal error: Cannot redeclare class edit in vfs://controllers/admin/company/edit.php on line 2

看起来我们对同一个文件使用include/require多次。

require($controller);

让我们将其更改为

require_once($controller);

现在让我们面对你的问题,编写一个测试来检查目录company和文件company.php是否相互干扰。

$this->assertEquals("company::get()", Route(vfsStream::url('controllers'), '/admin/company', 'get'));
$this->assertEquals("company::get()", Route(vfsStream::url('controllers'), '/admin/company/get', 'get'));

正如您在问题中所说,在这里我们得到了 404 异常:

$ phpunit test.php
PHPUnit 3.7.13 by Sebastian Bergmann.

.....E.

Time: 0 seconds, Memory: 2.00Mb

There was 1 error:

1) RouteTest::testControllerWithSubControllers
Exception: /admin/company

C:\xampp\htdocs\r\Route.php:32
C:\xampp\htdocs\r\test.php:69

FAILURES!
Tests: 7, Assertions: 10, Errors: 1.

这里的问题是,我们不知道何时进入子目录以及何时使用 .php 文件中的控制器。所以我们需要明确你想要发生的事情。我假设以下,因为它是有道理的。

  • 仅当控制器不包含请求的方法时才输入子目录。
  • 如果控制器和子目录都不包含请求的方法,则抛出 404

所以不要像这里搜索目录:

while ((is_null($segment = array_shift($segments)) !== true)
  && (is_dir($root . $controller . $segment . '/'))) {
    $controller .= $segment . '/';
}

我们需要搜索文件。如果我们发现一个文件不包含请求的方法,那么我们搜索一个目录。

function Route($root, $request_uri, $request_method) {
  $segments = array_filter(explode('/', $request_uri), 'strlen');

  if ((count($segments) == 0) || (is_dir($root) === false)) {
    return true; // serve index
  }

  $all_segments = array_values($segments);
  $segments = $all_segments;

  $directory = $root . '/';
  do {
    $segment = array_shift($segments);
    if(is_file($controller = $directory . $segment . ".php")) {
      $class = basename($controller, '.php');
      $method = isset($segments[0]) ? $segments[0] : $request_method;

      require_once($controller);
      if (method_exists($class = new $class(), $method)) {
        return call_user_func_array(array($class, $method), array_slice($segments, 1));
      }
    }
    $directory .= $segment . '/';
  } while(is_dir($directory));

  throw new Exception('/' . implode('/', $all_segments), 404); // serve 404
}

此方法现在按预期工作。

我们现在可以添加更多的测试用例,但我不想更多地扩展它。如您所见,运行一组自动化测试以确保您的函数中的某些内容正常工作非常有用。它对调试也很有帮助,因为您可以知道错误发生的确切位置。我只是想让您开始了解如何进行 TDD 以及如何使用 PHPUnit,以便您可以自己调试代码。

“授人以鱼,养其一日。授人以渔,养其一生。”

当然,您应该在编写代码之前编写测试。

这里还有一些可能很有趣的链接:

于 2013-02-14T11:51:57.223 回答
2

尽管您的魔术HVMC 方法对开发人员来说很方便.. 它可能会成为一个性能杀手(所有 stats/lstats)。我曾经使用类似的方法将 FS 映射到路由,但后来放弃了魔法,并用一些好的老式硬编码配置取而代之:

$controller_map = array(
  '/some/route/' => '/some/route.php',
  '/anouther/route/' => 'another/route.php',
  # etc, etc, ...
);

也许它不像您现有的那样优雅,并且每次添加/删除控制器时都需要进行一些配置更改(srsly,这不应该是一项常见任务..)但更快,消除了所有歧义,并且摆脱了所有无用的磁盘/页面缓存查找。

于 2013-02-19T16:14:20.463 回答
1

抱歉,我没有时间测试我的解决方案,但这是我的建议:

while ((is_null($segment = array_shift($segments)) !== true)
    && (is_dir($root . $controller . $segment . '/'))
    && ( (is_file($controller = $root . $controller . $segment . '.php') 
        && (!in_array(array_shift(array_values($segments)), ['get','post']) || count($segments)!=0 ) ) ) {
      $controller .= $segment . '/';
  }

对上述代码的一个简单解释是,如果遇到既是文件又是目录的路由,检查它是否通过get / post成功,或者它是否是$segments数组中的最后一个段。如果是,则将其视为文件,否则,继续将段添加到$controller变量。

虽然我给出的代码示例只是我的想法,但它还没有经过测试。但是,如果您在比较中使用此工作流程,您应该能够完成它。我建议您遵循smassey的回答并继续为每个控制器声明路由。

注意:我在 *array_values* 上使用 *array_shift* 所以我只拉下一个段的值而不篡改$segments数组。[编辑]

于 2013-02-20T11:44:45.543 回答