Catalyst 技巧:将请求参数映射到模型

介绍

处理传入的请求参数(查询和主体参数)是几乎所有 Perl Catalyst 应用程序都需要应对的事情。不幸的是,Catalyst 在这方面表现不佳,没有给您太多指导,而且内置的处理方式也有很多不足之处。在本博客中,我将首先举例说明默认处理方式的工作原理、其中的一些问题以及 Catalyst 开发人员多年来如何尝试改进它(在我看来,成功不大;我可以这么说,因为一半的重做都是我的错 ;))。

Catalyst 如何处理请求主体和查询参数

默认情况下,传入的查询和正文参数会映射到 Catalyst 请求对象:

$c->request->query_parameters
  $c->request->body_parameters

`query_parameters` 让您可以访问在请求 URL 的“查询”部分中传递的参数。例如,如果您的 URL 是 `https://example.com/page/?aaa=1&bbb=2`,则 `query_parameters` 将返回以下 hashref:

+{
    aaa => "1",
    bbb => "2",
  }

`body_parameters` 方法可让您访问经典的 HTML 表单 POST 主体。例如,如果您有如下 HTML 表单:

当用户点击“提交”按钮时,你会期望“body_parameters”中出现以下 hashref:

+{
    username => "$USERNAME",
    password => "$PASSWORD",
  }

(用用户在表单中输入的内容替换 $USERNAME 和 $PASSWORD)。

两种方法都返回键值对的 hashref,其中字段或参数中的键以及值是标量或 arrayref(取决于请求中给定字段是否有一个或多个值)。

对于基本应用程序来说,这种方法效果不错,但存在一些问题。首先,键可以是标量或数组引用,这很烦人,需要你编写大量防御性代码,例如:

my $username = $c->req->body_parameters->{username};
$username = ref $username eq 'ARRAY' ? $username[-1] : ($username);

或者干脆忽略这个问题,这样可能会给自己带来安全问题。说到安全问题,我不知道我见过多少次这样的代码,将传入的主体参数直接传递到 DBIx::Class 对象中:

my $new_user = $c->model('Schema::User')->create($c->req->body_parameters);

这是一个痛苦的世界,因为你基本上是将用户提交的内容(或你的网站黑客提交的内容)直接传递给 DBIC `create`。你至少需要对传入的内容更加挑剔:

my $new_user = $c->model('Schema::User')->create(
    username => ref($c->req->body_params->{username}) eq 'ARRAY' ? $c->req->body_params->{username}[-1] : $c->req->body_params->{username},
    password => ref($c->req->body_params->{password}) eq 'ARRAY' ? $c->req->body_params->{password}[-1] : $c->req->body_params->{password},
  );

此时,您开始拥有大量丑陋的代码,甚至还没有开始进行表单验证。所有这些重复的代码很容易产生难以发现的拼写错误:

my $new_user = $c->model('Schema::User')->create(
    username => ref($c->req->body_params->{username}) eq 'ARRAY' ? $c->req->body_params->{usrname}[-1] : $c->req->body_params->{usernme},
  ...

我在 Catalyst 应用程序中看到过很多类似的拼写错误问题,它们可能会导致难以发现的问题,因为在 Perl 中,哈希反引用中的拼写错误通常不会导致严重的运行时错误,您只会在意外位置得到“undef”的值。我在 Catalyst 代码中看到过这个问题,它已经存在多年了。

我还经常看到控制器中一行行的参数处理代码。事实证明,参数处理是 Web 应用程序中程序员可能面临的最大工作之一,尤其是当应用程序变旧并且您需要在不破坏向后兼容性的情况下引入新功能时。这可能会导致控制器非常长且丑陋,使得遵循请求到响应周期中的逻辑流程变得困难。

您可以通过启用 `use_hash_multivalue_in_request` 配置选项来解决“它是值还是数组引用?”问题。这将为您提供一个 Hash::MultiValue 对象,而不是请求参数的 hashref。除其他事项外,它还可以轻松地说“当有多个值时,只给我最后一个值”,这几乎总是正确的,因为它的合法用途通常围绕 HTML 表单技巧,其中某些字段类型(如复选框)不容易知道用户何时明确设置了“关闭”状态。有关更多信息,请参阅配置。但是,这并不能真正解决其他问题,例如容易出现拼写错误或用大量参数调整代码弄脏控制器。

将传入的请求参数映射到模型

多年来,我在遇到此问题时使用的一个技巧是使用 Catalyst 模型作为请求参数的容器。此模型将 hashref 转换为具有方法的实际对象,这意味着任何拼写错误都会在运行时快速被发现。此模型也是粘贴验证和传入值过滤器的好地方,也是粘贴涉及这些参数的复杂逻辑的好地方。让我们保持简单,看看如何对已经描述的示例登录表单执行此操作:

package Example::Model::Params::Login;

use Moose;
use Valiant::Validations;
use Valiant::Filters;

extends 'Catalyst::Model';
with 'Catalyst::Component::InstancePerContext';

sub build_per_context_instance {
  my ($self, $c) = @_;
  my $body = ref($self)->new(%{$c->req->query_parameters}, ctx=>$c);
  return $body->validate; # ->validate returns '$self' for chaining
}

has ctx => (is=>'ro');

has username => (
  is => 'ro',
  validates => [
    presence => 1,
    length => {
      maximum => 64,
      minimum => 1,
    },
  ],
);

has password => (
  is => 'ro',
  validates => [
    presence => 1,
    length => {
      maximum => 64,
      minimum => 1,
    },
  ],
);

has user => (
  is => 'ro',
  lazy => 1,
  predicate => 'has_user',
  default => \&_find_user,
  validates => [
    presence => {message=>'User Not Found with credentials.'},
  ],
)

filters_with 'Truncate', max_length=>100;

sub _find_user {
  my $self = shift;
  my $user = $self->ctx
    ->model('Schema::User')
    ->find({username=>$self->username});

  return unless $user && $user->password_eq($self->password);  
  return $user;
}

这干净利落地封装了获取 POST 参数的整个工作,确保它们有效并且参数与数据库中的用户匹配(并且给定的密码通过 `password_eq` 方法与数据库中的最新密码匹配,这是我留给你的练习;不要忘记在数据库中对你的密码进行哈希处理!)。

您可以在类似于以下的控制器中使用它:

package Example::Controller::Session;

use Moose;
use MooseX::Attributes;

extends 'Catalyst::Controller';

sub login :Path Args(0) {
  my ($self, $c) = @_;
  my $params = $c->model('Params::Login');
  return $c->login_user($params->user) if $params->valid;
  return $c->stash(params => $params);
}

在这个例子中,我们将传入的请求主体映射到 `$params`,如果对象有效,我们将执行登录工作流程(通过 `login_user($user)`,这种方法我再次留给您想象,但可能涉及将用户 ID 存储在会话中并重定向到某种“您已登录”页面)。如果不是,我们将 `$params` 保留在存储中并让视图检查它是否存在错误,并在您喜欢的任何视图系统中向用户显示这些错误。

结论

在现实生活中,您可能会使用身份验证插件来实现类似的功能,但这里的总体思路基本上可以映射到任何类型的传入查询或请求主体,即使是通过 API 请求的查询或请求主体,这些请求可能采用 JSON 而不是表单 POST。您获得的是清晰的关注点分离,可提高代码的可读性和长期可维护性。我非常喜欢这个想法,并开始大量使用它,以至于我尝试将模式封装在 CatalystX::RequestModel 中。CPAN 上可以执行类似操作的其他方法是 HTML::FormHandler,尽管它往往更侧重于验证和 HTML 表单字段生成,因此可能比您想要的更复杂。

本博客中提到的模块包括 Catalyst 和 Valiant

奖金创意

我经常使用类似的方法将 Catalyst 会话(也表示为哈希引用)包装在模型中,以便为会话提供强类型接口。您能找出代码吗?