【问题标题】:Ambiguity in HMVC RoutingHMVC 路由中的歧义
【发布时间】:2013-01-29 20:59:40
【问题描述】:

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

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 控制器服务于请求(应该如此),但是通过 GETdomain.tld/admin/company/get/ 访问 domain.tld/admin/company/ 直接引发 404 错误,因为 company段被映射到相应的目录,即使剩余的段在文件系统中没有映射。我该如何解决这个问题?最好不要在磁盘上花费太多精力。

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

【问题讨论】:

  • 您应该使用正则表达式来匹配有效路由并提取值,而不是这种疯狂的做法
  • @tereško:我也使用基于正则表达式的路由,但最好不要麻烦手动定义它们,特别是在开发过程中。

标签: php model-view-controller routing filesystems hmvc


【解决方案1】:

对于像这样的关键内容,使用 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-&gt;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 变量有问题。如果我们现在使用调试器(或一些 echos)检查 Route 函数中的以下行。

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

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

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

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

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

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

PHPUnit 会自动读取该 cmets 并检查在执行此方法时是否抛出 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,所以你可以自己调试你的代码。

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

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

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

【讨论】:

  • 你应该得到赏金,只是因为你在答案中付出的努力。无论如何,您似乎过度关注 TDD,我无法用我的实时代码重现 Fatal error: Class 'edit.php.php' 错误,但无论哪种方式,我面临的问题都可以在您的 "问题就在这里是,我们完全不知道何时进入子目录以及何时使用 .php 文件中的控制器。” 声明(让我在后续评论中继续这一点,因为我已达到允许的最大值长度)。
  • 如果文件优先于目录。考虑我有控制器admin 和子管理员控制器posts。例如,当我访问domain.com/admin/posts/ 而不是看到列出最新帖子的posts::get() 方法时,我将获得admin::posts() 方法,这些方法可能存在也可能不存在。当然,问题存在两种方式,但如果文件具有优先级,则分层路由仅在不存在冲突时才有效。我一直在寻找一种使路由更智能的方法,以便推断出哪个级别的服务是有意义的。
  • 一个简单的解决方案是预先获取所有控制器的所有路径并要求每个控制器,以便可以使用反射检查它们的方法。当然,这将使它与匹配正则表达式一样微不足道,但性能不会是最好的,特别是在磁盘命中方面。另一个(虽然更复杂)的解决方案是来回尝试匹配目录,然后是文件,然后是现有方法,iff none 被发现它会上升一个级别并尝试另一个选项,直到所有段/文件系统路径已用尽。
  • 我猜这两种解决方案的混合将是最有效的,即预加载所有控制器文件(可能缓存它们的属性,以便磁盘不会受到太多影响)和方法/参数信息通过反射直接匹配 n, n-1, n-2, ..., 1, 0 个级别。我仍然不确定这一点,因此问题/赏金。
【解决方案2】:

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

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

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

【讨论】:

  • 是的,这是一个很好的建议,我已经这样做了,但您肯定会看到拥有这样一个 FS 路由器的好处,特别是在开发过程中。这个想法是稍后使用像 APC 这样的内存缓存来优化磁盘查找。如果您查看gist.github.com/alixaxel/4692575,您会发现您可以使用 FS 提供更方便的 HTTP 状态代码,而不是使用基于“盲”正则表达式的路由。
  • 虽然这并没有为特定问题提供解决方案,但它是提供明确路由策略的唯一答案,奖励赏金。
【解决方案3】:

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

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大批。 [编辑]

【讨论】:

  • 我可以看到您要做什么,但 is_dir / is_file 组合不会因为:1)您只调用一次 array_shift - 所以 /admin/posts/会错误地尝试指向/admin/admin.php[/posts/posts.php],并且,2)控制器可能直接位于根目录中(即不在子目录中)。
猜你喜欢
  • 2012-04-18
  • 1970-01-01
  • 1970-01-01
  • 2014-12-06
  • 2013-10-04
  • 2010-09-08
  • 1970-01-01
  • 1970-01-01
  • 2013-03-16
相关资源
最近更新 更多