DBIx::Recordset 与 DBI

在应用程序中使用DBI时遇到的问题 - 对表和数据变异的容忍度 - 易出错且繁琐的查询构建 - 手动且复杂的数据库数据到Perl数据结构的映射

简洁的CGI-SQL交互(通过“CGI单行脚本”控制数据库) - 表访问的控制和监控 - 可扩展性 - 表单数据变化 - 表拆分

示例代码 - DBIx::Recordset代码版本 - DBI代码版本 - 经验结果

结论 致谢

介绍

撰写这篇文章真是一场噩梦。不,实际上,撰写大部分内容相当有趣 - 只是当我不得不将DBIx::Recordset代码的功能等价地用DBI编写出来时,我开始大汗淋漓。只有当我写完了大量用于执行与我的DBIx::Recordset小山丘相当的任务的DBI代码时,我才能松一口气。自从开始使用DBIx::Recordset以来,我就不愿意在需要DBI作为数据库API的项目上工作。虽然这听起来像是一种文字游戏,但理解DBI是Perl的标准数据库接口,但不应该是大多数需要数据库功能的应用程序的接口,这一点至关重要。

确定特定模块/库是否与任务的级别相匹配的关键方法是在你能够做你想要的事情之前,你必须编写多少行“预备代码”。换句话说,你的领域中的复杂操作和数据是否可以通过这种方式统一处理?在DBI的情况下,我们可以这样说,它通过将它们简化为对DBI API的单次调用,使得连接、语句准备和数据检索的任务变得容易处理。然而,实际应用中存在更大、更实用的复杂单元,在这些方面,DBI API的表现不足。对我来说,DBI这样的模块,其唯一的设计意图是向各种各样的数据库提供统一的API,缺乏这样的高级功能,并不令人惊讶。但让我感到惊讶的是,许多Perl程序员,其中一些人可能在他们的职业生涯中某个时刻上过软件工程课程,会做出这样的错误判断。因此,责任在于程序员的判断,而不是DBI。

在大多数情况下,DBI的API和Perl应用程序之间的差距是通过不加区分地将通用应用程序级功能与当前应用程序的特定功能混合在一起来弥补的。这使得很难在其他部分的应用程序或完全不同的应用程序中重用通用例程。扩展DBI API以进行应用程序级数据库应用的另一种不良方式是开发一组通用应用程序级工具,但不公布它们。因此,随着从两个阵营使用不同的通用应用程序级API构建更大规模的工具,必须在代码体之间使用胶带修补调用约定的不一致。在应用程序中误用DBI的最终方式是直接使用它。

然而,有一个鲜为人知但公开可用的CPAN模块,它能够稳健且方便地将DBI和应用程序级编程之间的差距连接起来,那就是DBIx::Recordset。它建立在DBI之上,与数据库驱动应用程序的构思水平非常匹配,在大多数情况下,应用程序设计规范中的一句话就相当于DBIx::Recordset中的一行。

在应用程序级使用DBI时存在的问题

对表和数据变化的耐受性不强

表变化——从表中添加、删除或重新排列字段,或数据变化——添加、删除或重新排列数据库提交的输入源的部分,可能会破坏大量对DBI API的调用。这是因为大多数例程都期望并返回数组或数组引用,因此当期望的数组缩小或增长时就会失败。例如,以下DBI代码

 $dbh->do("INSERT INTO students (id,name) VALUES (1,$name)");

在从学生表中删除字段后将会崩溃。然而,等效的DBIx::Recordset代码(1)

 DBIx::Recordset->Insert({%dsn,'!Table'=>'students',%dbdata});

将无论学生表或%dbdata的构造性或破坏性变化都能正常工作。如果%dbdata中的字段值对少于表中的字段值,则插入操作将使用相应的字段。如果%dbdata中有无关字段,则默认情况下会静默忽略额外字段。

现在,这种对DBI使用的耐受性不强的引入意味着表或输入数据的任何变化都需要修改源代码。对于一些人来说,这种刚性有其价值,因为它迫使数据库提交的源和目标在源代码中明确表示。然而,对于其他Perl程序员来说,这种刚性不过是对他们高度培养的懒惰感的一种强加。

易于出错且繁琐的查询构建

将查询字符串以占位符或字面量表示法呈现给DBI API。以下是一个DBI占位符用法的示例

 $sql='insert into uregisternew
        (country, firstname, lastname, userid, password, address1, city,
        state, province, zippostal, email, phone, favorites, remaddr,
        gender, income, dob, occupation, age)
        values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)';

 my @data=($formdata{country}, $formdata{firstname}, $formdata{lastname},
        $formdata{email}, $formdata{password}, $formdata{address},
        $formdata{city},  $formdata{state},  $formdata{province},
        $formdata{zippostal}, $formdata{email}, $formdata{phone},
        $formdata{favorites}, $formdata{remaddr}, $formdata{gender},
        $formdata{income}, $formdata{date}, $formdata{occupation},
 $formdata{age});

        $sth2 = $dbh->prepare($sql);
        $sth2->execute(@data);
        $sth2->finish();

这是我在最近一个合同中不得不小心翼翼避开的陷阱的略微修改版本。这段代码有几个问题在等待发生。首先,你必须祈祷问号占位符的数量与你插入的字段数量相同。其次,你必须手动确保插入语句中的字段名称在位置和数量上与数据数组相对应。

如果使用DBI的字面量表示法开发相同的查询,你会遇到相同的问题,并且还需要更多的代码来手动引用数据并将其嵌入到查询字符串中。

相比之下,DBIx::Recordset的Insert()函数接受一个哈希,其键是数据库字段名称,值是要插入的值。使用这种数据结构消除了上述提到的对应难题。此外,DBIx::Recordset在调用DBI API时生成占位符表示法。

因此,在不损失功能的前提下(2),上面的整个代码块可以写成

 DBIx::Recordset->Insert({%dsn,%formdata});

事实上,DBI代码并不等同于DBIx::Recordset代码,因为连接和数据库操作始终是针对DBI API的单独调用。为了简洁起见,省略了使用DBI所需进行的额外工作。

手动且复杂的数据库数据到Perl数据结构的映射

操作 DBI DBIx::Recordset
单行提取
selectrow_array
selectrow_arrayref
$set[0]
多行提取
fetchall_arrayref  
selectall_arrayref 
fetchrow_array     
fetchrow_arrayref  
fetchrow_hashref
 for $row (@set) {...}
       OR    
while ($href=$set->Next())

在DBI中,数据库记录检索是手动、复杂且在大多数情况下对表变化无法容忍的。手动意味着执行查询不会自动将查询结果映射到任何本地Perl数据结构上。复杂意味着DBI可以以多种方式返回数据:数组、数组引用和哈希引用。

然而,在DBIx::Recordset中,检索选定记录集可以是(3)自动、简单且对字段突变有容忍度。所谓自动,意味着请求记录会导致结果集自动绑定到一个哈希表中。(4)不需要调用任何函数来执行此转移。检索过程很简单,因为获取结果的方式只有通过哈希引用。由于DBIx::Recordset返回一个哈希,因此字段是通过名称而不是位置来引用的。这种策略对所有表突变都有很好的鲁棒性。

在了解了DBIx::Recordset解决DBI使用中一些棘手问题的方案后,我们现在转向探讨DBIx::Recordset在以下领域提供的丰富应用程序级优势

  1. 简洁的CGI到SQL转换
  2. 表格访问控制和监控
  3. 可伸缩性

虽然这些主题可能听起来很令人印象深刻,但DBIx::Recordset旨在通过一行Perl代码实现每个目标(5)

简洁的CGI-SQL交互(通过“CGI单行”进行数据库控制)

假设查询字符串中的键与目标数据库表中的字段名匹配,DBIx::Recordset可以通过一行Perl代码将表单数据从查询字符串传输到数据库中

 DBIx::Recordset->Insert({%formdata,%dsn});

一行DBIx::Recordset代码也可以通过表单数据驱动记录集的检索,并通过结果集迭代

 # here we: SELECT * FROM sales and automatically tie
 # the selected records to @result

 *result = DBIx::Recordset->Search({
        %dsn,'!Table'=>'sales',%formdata
        });

 # here we iterate across our results...
 map { 
  printf ("<TR>Sucker # %d purchased item # %s on %s</TR>", 
        $_->{customer_id}, $_->{item_id}, $_->{purchase_date}) 
 } @result;

上述代码会自动添加引号,无需繁琐的连接、准备和执行仪式。

DBIx::Recordset还提供了辅助函数,用于创建记录集“上一页-下一页-第一页-最后一页”导航的HTML

 $nav_html = $::result -> PrevNextForm ({
        -first => 'First',  -prev => '<<Back', 
        -next  => 'Next>>', -last => 'Last',
                -goto  => 'Goto #'}, 
        \%formdata);

在这种情况下,我们使用类型glob的标量方面,它提供了对创建的记录集方法的面向对象访问。

CGI的一个最终优点与浏览器发送空表单字段作为空字符串的事实有关。虽然在某些情况下,您可能希望将空字符串传播到数据库作为SQL null,但有时也希望忽略空表单字段。您可以通过DBIx::Recordset的Insert()Update()函数的‘!IgnoreEmpty’哈希字段指定您希望的行为。

表格访问控制和监控

DBI中的数据库句柄是对数据库的全面访问权限,可以添加、检索或删除任何可以使用具有相同登录的数据库控制台界面执行的操作。问题是,应用程序数据库代码的语义和易变性并不是自我一致的,而是随着数据库权限更改而变化。(6)相反,DBIx::Recordset的句柄结构更严密。句柄通过一组属性创建和配置:表写入模式、可访问的表以及将数据库使用情况记录到日志的方法等。

DBIx::Recordset提供的急需的表格访问控制的第一种形式是指定特定数据库连接将允许如何更改数据库表。通过使用二进制值字符串,您指定允许的写入操作的子集(特别是none/insert/update/delete/clear)。这些设施通常在定义数据库访问级别时需要。例如,一个企业内部网的数据库可以给予销售员工插入访问权限,客户服务更新访问权限,处理删除访问权限,以及技术支持完整访问权限。要在普通的DBI中实现此类约束,将产生一个混乱的if-then风暴和2-3次自杀尝试。使用DBIx::Recordset,只需为每个企业内部网部门创建一个适当配置的连接即可。

与写权限问题相关的是可以访问哪些表的问题。控制表访问仅仅是连接设置哈希中的一个更多键值对。最后,为了监控数据库句柄的使用,只需为每个句柄设置一个调试文件。

因此,假设包公司::数据库有一个包含内部网络写模式的 %write_mode 哈希,一个包含每个句柄日志文件的 %log_file 哈希,以及一个包含内部网络每个成员的表的 %table_access 哈希,可以如下指定整个内部网络的表及其访问和用法日志

{ 
  package company::database; 

  for (keys %write_mode) {

  *{$handle{$_}} = 
        DBIx::Recordset->Setup({%dsn, 
        '!Writemode' => $write_mode{$_}, 
        '!Tables'    => $table_access{$_}
        });

   DBIx::Recordset->Debug({
                '!Level' => 4,
                '!File'  => $log_file{$_},
                '!Mode'  => '>'
                });
   }
}

可伸缩性

操作 需要更改的代码(DBI) 代码更改(DBIx::Recordset)
在网页中添加或删除表单元素,但仍然要正确提交生成的查询字符串。 对于表单的每次更改(以及查询字符串),都需要修改数据库代码。
将一个未归一化的主表拆分为多个“卫星”表,主表中有外键引用卫星表。 对于每个拆分表,需要添加额外的术语到 WHERE 或 JOIN 子句。

无论项目规划得多好,原型设计和早期开发通常都是渐进的过程。如果数据库处理程序在 HTML 和数据库重新设计面前保持不变,则可以节省大量开发时间。在这方面,DBIx::Recordset 在项目原型设计阶段比 DBI 更加可行。在展示了 DBIx::Recordset 在面对表突变时的可伸缩性之后,本节将展示 DBIx::Recordset 在面对表单数据变化以及数据库表拆分时的可伸缩性。

表单数据变化

假设你正在开发一个用户注册表单,它将表单数据提交到 db-commit.cgi 以插入到数据库中

#!/usr/bin/perl
use CGI (:standard);
$formdata{$_} = param{$_} for param();
if ($#(DBIx::Recordset->Search(
 { %dsn,
  '!Table'  => user_registration,
  'username' => $formdata{username}
 })) >= 0) {
 &username_taken_error;
} else {
 DBIx::Recordset->Insert(
 { %dsn,
  '!Table'  => user_registration,
  %formdata
 }
}

现在假设你决定向一个表和相应的表单中添加一个新的字段 AGE。在 DBI 中,插入查询必须修改以适应变化。因为 DBIx::Recordset 的插入接受哈希引用,所以不需要进行代码修改。当然,我可以听到 DBI 用户在尖叫:“我可以开发一个库将表单数据转换为哈希,并将其转换为查询字符串。”当然,我的回应是:“但你难道没看到这是一个非标准 (7)API,它将不得不与其他人的非标准解决方案粘合在一起吗?”

表拆分

作为 DBIx::Recordset 对架构变化灵活性的另一个示例,考虑这种情况:一个表被拆分,可能是为了归一化的原因。因此,在核心表中,你曾经明确地将用户的姓名编码到 user_name 字段中,现在有一个名为 user_name_id 的外键,它指向一个名为 user_name 的表,该表有一个名为 id 的字段。假设你后来还决定对其他字段进行同样的归一化,如 age-bracket 或 salary-bracket。使用普通的 DBI,每次查询旨在从相关的每个表中检索所有字段时,查询都必须重写以适应主表的拆分。使用 DBIx::Recordset,无需重写任何查询,因为表以 DBIx::Recordset 的数据库元分析可识别的格式描述。

示例代码

在一个最近的合同中,我必须将一个用户注册表(命名为 uregister)复制到一个新表(称为 uregisternew),该表具有旧表的全部字段,以及一些新的字段,用于存储用户的个人资料信息。

关于 DBIx::Recordset 版本代码的关键点是它是高度定义性的:几乎没有数据库机制会干扰代码的主逻辑,从而允许人们专注于记录集从一个表迁移到另一个表。

DBIx::Recordset 版本的代码

 #!/usr/bin/perl

 =head1

 uregisternew is a table with all the fields of uregister plus a few
 profile fields (ie, salary bracket, occupation, age) which contain a
 positive integer which serves as index into the array for that
 particular profile field.

 The purpose of this script is to copy over the same fields and
 generate a valid array index for the new profile fields.

 =cut

 use Angryman::Database;
 use Angryman::User;
 use DBIx::Recordset;
 use Math::Random;
 use strict;

 $::table{in}  = 'uregister';
 $::table{out} = 'uregisternew';


 # connect to database and SELECT * FROM uregister
 *::uregister = DBIx::Recordset->Search ({            
        %Angryman::Database::DBIx::Recordset::Connect, 
        '!Table' => $::table{in}  
        });


 # because we will re-use the target table many times, we separate the 
 # connection and insert steps with this recordset
 *::uregisternew = DBIx::Recordset->Setup({  
        %Angryman::Database::DBIx::Recordset::Connect, 
        '!Table' => $::table{out} 
        });


 # iterate through the recordsets from the old table:
 for my $uregister (@::uregister) {
     &randomize_user_profile;
     # INSERT 
        # the old table data into the new table and
        # the computed hash of profile data
    $::uregisternew->Insert({%{$uregister},%::profile});
 }

 # Angryman::User::Profile is a hash in which each key is a reference 
 # to an array of profile choices. For example:
 # $Angryman::User::Profile{gender} = [ 'male', 'female' ];
 # $Angryman::User::Profile{age} = ['under 14', '14-19', '20-25', ... ];
 # Because we don't have the actual data for the people in uregister,
 # we randomly assign user profile data over a normal distribution.
 # when copying it to uregisternew.
 sub randomize_user_profile {
    for (keys %Angryman::User::Profile) {
        my @tmp=@{$Angryman::User::Profile{$_}};
        $::profile{$_} = random_uniform_integer(1,0,$#tmp);
        $::profile{dob}='1969-05-11';
    }
 }

DBI 版本的代码

 #!/usr/bin/perl


 =head1
 uregisternew is a table with all the fields of uregister plus a few
 profile fields (ie, salary bracket, occupation, age) which contain
 a positive integer which serves as index into the array for that
 particular profile field.


 The purpose of this script is to copy over the same fields and
 generate a valid array index for the new profile fields.


 This file is twice as long as the DBIx::Recordset version and it 
 easily took me 5 times longer to write.
 =cut 


 use Angryman::Database;
 use Angryman::User;
 use DBI;
 use Math::Random;
 use strict;


 $::table{in}  = 'uregister';
 $::table{out} = 'uregisternew';


 # connect to database and SELECT * FROM uregister
 my $dbh = DBI->connect($Angryman::Database::DSN, 
                        $Angryman::Database::Username, 
                        $Angryman::Database::Password);
 my $sth = $dbh->prepare('SELECT * FROM uregister');
 my $ret = $sth->execute;


 &determine_target_database_field_order;


 # because we will re-use the target table many times, we separate the 
 # connection and insert steps with this recordset


 # iterate through the recordsets from the old table:
 while ($::uregister = $sth->fetchrow_hashref) {


     &randomize_user_profile;
     &fiddle_with_my_data_to_get_it_to_work_with_the_DBI_API();


     # INSERT 
         # the old table data into the new table and
         # the computed hash of profile data
     my $sql = "INSERT into $::table{out}($::sql_field_term) values($::INSERT_TERM)";
     $dbh->do($sql);
 }


 # Angryman::User::Profile is a hash in which each key is a reference 
 # to an array of profile choices. For example:
 # $Angryman::User::Profile{gender} = [ 'male', 'female' ];
 # $Angryman::User::Profile{age} = ['under 14', '14-19', '20-25',  ];
 # Because we don't have the actual data for the people in uregister,
 # we randomly assign user profile data over a normal distribution.
 # when copying it to uregisternew.
 sub randomize_user_profile {
     for (keys %Angryman::User::Profile) {
         my @tmp=@{$Angryman::User::Profile{$_}};
         $::profile{$_} = random_uniform_integer(1,0,$#tmp);
     }


     $::profile{dob}='';
 }


 # Hmm, I cant just give DBI my data and have it figure out the order
 # of the database fields... So here he we go getting the field
 # order dynamically so this code doesnt break with the least little
 # switch of field position.
 sub determine_target_database_field_order {


     my $order_sth = $dbh->prepare("SELECT * FROM $::table{out} LIMIT 1");
     $order_sth->execute;


 # In DBIx::Recordset, I would just say $handle->Names()... but here we 
 # must iterate through the fields manually and get their names.


     for (my $i = 0; $i < $order_sth->{NUM_OF_FIELDS}; $i++) {
         push @::order_data, $order_sth->{NAME}->[$i];
     }


     $::sql_field_term = join ',',  @::order_data;


 }


 # As ubiquitous as hashes are in Perl, the DBI API does not
 # offer a way to commit hashes to disk.
 sub fiddle_with_my_data_to_get_it_to_work_with_the_DBI_API {


     my @output_data;
     for (@::order_data) {
         push @output_data, $dbh->quote
             (
              defined($::uregister->{$_}) 
              ? $::uregister->{$_} 
              : $::profile{$_}
              );
    }


    $::INSERT_TERM=join ',', @output_data;
 }

经验结果

DBI DBIx::Recordset
1.4 秒(1,2) 3.7 秒(3,4)

在DBI和DBIx::Recordset下执行示例代码所需的平均、最小和最大秒数。代码在拥有250个用户的数据库上运行。

结论

DBI因为简单且可移植而超越了ODBC API作为数据库接口。由于DBIx::Recordset建立在DBI之上,它保持了这些优势并提高了DBI的简洁性。因为它还为DBI添加了许多必需的应用级功能,因此它是数据库驱动Perl应用的明显选择。

DBI的一个强劲的改进方案是Simon Matthews最近通过Template Toolkit插件简化DBI使用的努力。DBIx::Recordset的许多优势在DBI插件中要么是固有的,要么是由于其开发环境。例如,DBIx::Recordset允许通过其数据库处理函数的!Filter键来过滤记录集。由于Template Toolkit已经提供了许多通用且广泛使用的过滤器(例如,HTML、日期等),插件无需提供过滤功能。然而,Matthew的DBI插件使用了与DBI相同级别的抽象。这一不足加上插件缺乏应用级数据库便利性,使得插件与DBI具有相同的功能性,只是语法更优美,但仍然存在同样的问题。

话虽如此,当速度至关重要时,DBI比DBIx::Recordset更可取。DBI的速度优势归因于几个因素。首先,DBIx::Recordset建立在DBI API之上,因此在每个应用级数据库命令中至少有一个额外的函数调用开销。其次,DBIx::Recordset需要时间来解码其紧凑的输入代数并生成良好的SQL。

抛开理论不谈,我的经验和时间结果显示,当选择DBIx::Recordset而不是DBI时,你不会失去超过一秒钟或两秒钟的时间。考虑到DBIx::Recordset相对于DBI提供的东西:开发速度、表达能力的强大以及标准必需的应用级功能的可用性,这种减速是可以接受的。

即使时间限制导致人们决定DBIx::Recordset不适合最终产品,因为它比DBI稍微慢一些,但在早期原型设计或解决复杂问题并希望关注记录集的流动而不是管理这种流动的机制时,它可以特别有用。

致谢

我想感谢Gerald Richter(《[email protected]》)为编写DBIx::Recordset,对这篇手稿的早期版本提出意见,并为他提供的出色工具向我们和其他人提供免费帮助。

脚注

  1. 实际上,DBI代码并不等同于DBIx::Recordset代码,因为连接和数据库操作始终是针对DBI API的单独调用。为了简洁起见,省略了使用DBI所需的其他工作。
  2. DBIx::Recordset代码也更准确,因为它使用数据库元数据来确定哪些数据需要引用,而DBI使用基于字符串的启发式方法。
  3. DBIx::Recordset可以是自动且简单的,但你也可以在更手动模式下操作,以获得与DBI相当的时间/空间效率。
  4. 更确切地说,记录集中的每一行都是一个匿名散列,通过数组中的一个元素引用,该数组的名称由调用Search()函数时绑定的typeglob确定。
  5. 我现在迫不及待地想看到下一代的混淆Perl代码,因为主要的数据库操作现在只需要一行代码!
  6. 无论这个改变是由朋友还是敌人做出的。
  7. 诚然,将CGI查询字符串转换为散列的做法也不符合标准,但大多数高端Perl Web应用程序框架(例如HTML::Embperl和HTML::Mason)都自动提供这种转换,作为它们应用级API的一部分,以支持网站开发。
  8. 可能这就是为什么DBI书的封面上有猎豹的原因。.

标签

反馈

这篇文章有什么问题吗?请通过在GitHub上打开问题或拉取请求来帮助我们。