七、报告和其他输出
给你的程序员留些余地。告诉他(一)结果你一定有;(b)您希望获得的结果,如果每次运行额外收费不超过 25 美元;以及©如果可以在没有额外运行时间成本的情况下获得结果,那么这些结果将是方便的。(这往往是可能的。)
理查德·v·安德里,编程 IBM 650 磁鼓计算机和数据处理机,1958 年
正如我在第四章中所说,数据库是现实的部分模型,应用的 CRUD 部分(第五章和第六章)主要用于保持模型与现实同步。但是为了让您的应用发挥更大的作用,您必须从中获得一些输出,要么是报告,要么是其他输出,比如套用信函。
由于您的报告成本远低于 IBM 650(按 2013 年美元计算为 1600 美元/小时),您可以获得“您想要的结果”和“方便的结果”。如何做到这一点是本章的主题。
作为报告的查询
您可能会认为 SQL select
语句本身就是一个报告。例如,如果你想知道有多少 Front Range Butterfly Club 成员拥有各自的专长,你可以在终端窗口中启动mysql
命令,然后用一条语句得到报告。
mysql> select name, count(specialty_id) as count
-> from member join specialty using (specialty_id)
-> group by specialty_id order by name;
+----------------+-------+
| name | count |
+----------------+-------+
| brush-foots | 19 |
| gossamer-wings | 27 |
| sulphurs | 25 |
| swallowtails | 18 |
| whites | 25 |
+----------------+-------+
事实上,像这样的快速和肮脏的报告是非常有用的。我刚刚检查并发现世界事务会议(CWA)应用定义了 113 个这样的小报告。它们被办公室工作人员用于诸如“2013 捐赠阿尔法无计划年”和“版主 w/联系信息”之类的事情
CWA 办公室没有人使用mysql
命令,甚至不知道它的存在。相反,我创建了一个应用页面来编辑、保存和运行查询,助理协调员已经理解了 SQL 并开始使用它。我可能创建了三分之一的查询,而她创建了其余的。
图 7-1 显示了蝴蝶俱乐部查询页面,基于我为 CWA 创建的页面。
图 7-1 。查询页面
要创建一个查询,您需要输入它的标题和 SQL,然后单击 Save。正如您在图的底部看到的,所有的查询都被列出来了。当您单击查询旁边的“编辑”按钮时,它的列会加载到表单中进行编辑。要运行查询,您可以单击它旁边的运行按钮,或者在表单上保存并运行。您还可以删除一个查询,或者为新的查询获取一个空白表单。
Category 字段将查询分配到一个类别,只是为查询列表提供一些分组。
显然,这个页面遭受了极端形式的 SQL 注入,因为数据实际上是 SQL。有两种保护措施:(1)代码只允许将select
语句保存为查询;(2)有一个权限系统,如权限下拉菜单所示,我将在“基于角色的访问控制”一节中解释(这不是真正的 SQL 注入,因为这是将 SQL 注入到一个不期望的字段中;查询字段是期望 SQL 的。)
当你运行一个查询时,它的输出直接出现在页面上,在表单下,在一个有滚动条的分区中,如图 7-2 所示。
图 7-2 。显示运行查询结果的查询页面
正如您所猜测的,查询页面由一个query
表支持,如 MySQL Workbench 中的图 7-3 所示。
图 7-3 。查询表
由于我已经展示了许多使用Page
类的应用页面的例子,我将不在本章和下一章展示所有的代码,而只展示重点,这里的第一个是查询表单,如清单 7-1 所示。
清单 7-1 。查询表单
protected function show_form($data = null, $run = false) {
if (empty($data['category']))
$data['category'] = 'General';
$f = new Form();
$f->start($data);
if (isset($data['query_id']))
$f->hidden('query_id', $data['query_id']);
$f->text('title', 'Query Title:', 70, 'query title');
$f->textarea('query', 'Query:', 80, 3);
$f->text('category', 'Category:', 30, 'category');
$f->menu('permission', 'Permission:',
$this->ac->get_permissions(), false, 'query');
$f->button('action_save', 'Save');
$f->button('action_save_run', 'Save & Run', false);
$f->hspace(30);
$f->button('action_new', 'New', false);
$f->end();
if ($run && isset($data['query']))
if (stripos($data['query'], 'file ') === 0)
$this->message("Can't run file reports here");
else
$this->run($data['title'], $data['query']);
echo "<p style='margin-top:20px;'>";
$this->query_list();
}
注意方法Form::textarea
,我在第六章没有展示。这是对Form
类的一个非常简单的添加。
function textarea($fld, $label = null, $cols = 100,
$rows = 5, $readonly = false) {
$this->label($fld, $label, true);
$v = isset($this->vals[$fld]) ?
htmlspecial($this->vals[$fld]) : '';
echo "<br><textarea id=$fld name=$fld cols=$cols
rows=$rows>$v</textarea>";
}
当你需要新的东西时,你会想加入到Form
类中,就像我一样。
权限下拉菜单由返回所有可能权限的表达式填充
$this->ac->get_permissions()
它调用下一节描述的Access
类的方法。
在清单的底部附近,您会看到查询页面没有运行“文件报告 s”,这是一种我将在本章末尾的“通用报告页面”一节中介绍的报告它通过调用MyPage::run
方法来运行 SQL 查询报告,这几乎不需要任何代码,因为所有的工作都是由一个Report
类完成的,我将在“报告类:HTML 和 CSV 输出”一节中展示这个类
protected function run($title, $sql) {
echo '<div class=run>';
$stmt = $this->db->query($sql);
$r = new Report();
$r->html($title, $stmt);
echo '</div>';
}
正如你所猜测的,Report::html
方法将 HTML 直接输出到页面上,在run
类的div
中,CSS 限制了它的高度并允许它滚动。
.run {
overflow: auto;
max-height: 200px;
width: 600px;
border: 1px solid;
padding: 5px;
}
这个和其他一些我不会展示的 CSS 都是在实现查询页面的query.php
文件中定义的,因为它们是特定于这个页面的。您也可以将 CSS 放在应用范围的page.css
文件中,该文件由Page
类包含在每个页面中,但是我更喜欢将专门的 CSS 本地化。这个页面还定义了一些 JavaScript 函数,我将在清单 7-3 中展示。
最后,清单 7-1 中MyPage::show_form
方法的最后一行调用MyPage::query_list
,它按类别列出查询,如清单 7-2 所示。
清单 7-2 。MyPage::query_list
方法
function query_list() {
$stmt = $this->db->query('select * from query
order by category, title');
$cat = null;
while ($row = $stmt->fetch()) {
if ($cat != $row['category']) {
if (!is_null($cat))
echo "</table>";
echo "<h2>{$row['category']}</h2>";
echo "<table class=query-table>";
$cat = $row['category'];
}
echo "<tr>";
echo "<td nowrap valign=top>";
echo "<button type=button class=button οnclick=
'RunQuery(\"{$row['query_id']}\")'>Run</button>";
echo "<button type=button class=button οnclick=
'EditQuery(\"{$row['query_id']}\")'>Edit</button>";
echo "<button type=button class=button οnclick=
'DeleteQuery(\"{$row['query_id']}\",
\"{$row['title']}\")'>Delete</button>";
$t = htmlspecial($row['title']);
$q = htmlspecial($row['query']);
echo "<td width=100% valign=top>
<p class=name>$t<p class=query>$q";
}
echo "</table>";
}
这个函数没有什么特别的。我只说两点。首先,标题和查询都被htmlspecial
(它调用htmlspecialchars
)过滤,因为所有源自用户的内容都必须被过滤,以防止 XSS 攻击。
第二点是每个查询旁边的三个按钮在被点击时会启动 JavaScript 函数,如清单 7-3 所示。正如我提到的,它们出现在这个文件中,而不是应用范围的page.js
文件中。
清单 7-3 。查询 JavaScript 函数
function DeleteQuery(pk, name) {
if (confirm('Delete query "' + name + '"?'))
transfer('query.php', {'action_delete': 1, 'pk': pk});
}
function RunQuery(pk) {
transfer('query.php', {'action_run': 1, 'pk': pk});
}
function EditQuery(pk) {
transfer('query.php', {'action_edit': 1, 'pk': pk});
}
DeleteQuery
需要主键(query_id
)和名称,以便在confirm
对话框中使用。其他的只需要主键。如果你想回顾这三个动作方法,你可以在 Apress 网站的源代码/下载区找到它们,但是我可以告诉你,它们确实如你所料。
基于角色的访问控制
我刚才展示的这种查询工具带来了一个问题:不是每个用户都应该能够访问整个数据库。应该只有某些用户能够定义和/或运行查询。只要应用的功能有限,区别用户就无关紧要,就像 Front Range Butterfly Club 的会员和专业页面一样,但是现在应用变得越来越复杂。
我在第六章中描述的安全防御可以有效地将野蛮人挡在门外。现在需要的是一种让受邀嘉宾保持一致的方法。需要有一种方法来限制用户只能访问应用中他们应该访问的部分。
一种简单而灵活的控制用户访问权限的方法是基于角色的访问控制 (RBAC)。角色是可以分配给用户的工作职能或职责范围。每个角色包含一个或多个权限,这些权限是对访问系统资源、执行操作、访问部分数据库或对应用有意义的任何事情的批准。例如,假设权限member-view
允许查看成员数据(包括运行某些报告),权限member-edit
允许更新成员数据。那么角色member-maintenance
可以被定义为包含这两个权限。用户 Jack 和 Sally 的工作是维护成员数据,可以赋予他们角色member-maintenance
,这将允许他们完成自己的工作。另一个角色,比如说event-coordinator
,可能会被剥夺,这将阻止他们增加或修改俱乐部活动。活动委员会主席 Tom 拥有event-coordinator
角色,但没有member-maintenance
角色。
为了避免相关权限差别很小的角色激增,用户可以拥有多个角色。俱乐部秘书可能有member-maintenance
角色、event-coordinator role
和其他一些角色。
如果进行成员维护的用户需要额外的权限,他们只需添加到成员维护角色的定义中。不需要对用户本身做任何事情,因为他们与角色相关,而不是与权限相关。
如果你感兴趣,有一个 RBAC 的 ANSI 标准,ANSI INCITS 359-2004。你可以在csrc.nist.gov/groups/SNS/rbac
找到更多关于 RBAC 和标准的信息。
我将暂时离开主题,展示如何在您的应用中实现 RBAC,然后我将回到实现查询和报告。据我所知,我在这里所做的符合 ANSI 标准“核心 RBAC”
MySQL 中的 RBAC
MySQL 使用一个特权系统,如“更改”、“创建视图”和“更新”,来控制给定用户可以做什么。您可以将其视为 RBAC 的用户和权限部分(没有角色),用户和权限之间存在多对多的关系。特权可以与整个数据库相关联,也可以与特定的表甚至列相关联。
MySQL Workbench 确实允许您定义与权限(特权)相关联的角色(图 4-18 ),并将一个或多个角色分配给用户,但这些角色只是 MySQL Workbench 内置的一种便利,而不是 MySQL 本身的一个功能,它只处理特权。
正如我在第四章中所说的,MySQL 特权对于限制应用用户只能查看和编辑数据是有用的——不改变数据库模式——但是它们不适合定义应用角色,因为将应用操作映射到特定的表和列太繁琐了,而且让每个应用用户都成为数据库用户也不现实。在应用中实现 RBAC 要好得多。我只定义了两个 MySQL 用户:一个可以做任何事情的管理用户,和一个应用本身连接的应用用户。应用从不作为管理用户连接;只有 MySQL Workbench 和mysql
命令可以。
RBAC 数据库表
作为应用开发人员,您应该实现 RBAC 机制,并将权限与应用的各个部分相关联,但是策略—角色的定义及其对用户的分配——应该由应用管理员决定,也就是控制谁可以成为用户的人。
RBAC 的实现非常简单。角色和权限只是字符串,定义在两个各有一列的表中,如图图 7-4 所示。请注意,这两个表都有自然主键。
图 7-4 。权限和角色表
RBAC 的灵活性来自于两种多对多的关系:通过user_role
表从用户到角色,通过role_permission
表从角色到权限,如图图 7-5 所示。
图 7-5 。用户角色和角色权限表
首先,用一个admin
权限初始化permission
表,用一个admin
角色初始化role
表,用一行将两者关联起来的role_permission
表,用一行将该角色赋予管理用户的user_role
表。这是必要的,因为操纵 RBAC 机制本身的各种页面需要被限制给管理员,这可以通过将它们限制到admin
权限来实现。在访问这些页面之前,您需要进行初始化。您可以编写一个 PHP 页面来执行初始化,或者,因为它只需要做一次,只需用 MySQL Workbench 就可以了。
两个应用页面(本身需要admin
权限)维护permission
、role
和role_permission
表,如图图 7-6 和 7-7 所示。我不会展示这些页面的 PHP 代码,因为没有什么是我没有展示过的,但是你可以在 Apress 网站(www.apress.com
)的源代码/下载区找到它。
图 7-6 。许可页面
图 7-7 。角色页面
作为开发人员,您根据需要定义权限,将它们添加到permission
表中。没有硬性规定,但通常你希望每个应用页面(会员、专业、捐赠等。)拥有自己的权限。为查询分配额外的独有权限。如果您愿意,并且如果应用证明了这一点,您甚至可以得到更细粒度的服务。您拥有的独特权限越多,应用管理员就越能根据自己的需要定制角色。然而,权限太多,管理员很难理解每个权限的含义。
通常,只有 PHP 开发人员和任何被允许定义查询的人可以更新permission
表。也就是说,因为权限与应用资源或操作相关联,所以只有那些可以添加资源或操作的人需要能够定义权限。我将很快展示应用实际上是如何实施权限的;现在,只需将每个权限视为一个由唯一字符串表示的抽象。
我已经在第六章中详细描述了user
表,所以除了更新role
和role_permission
表的页面之外,应用管理员需要的是一种向用户分配角色的方法,从而导致user_role
表被更新。
向用户分配角色的一个好方法是简单地将选项添加到应用管理员可以访问的用户页面。这不同于用户用来更新他或她的个人数据(姓名、地址、电子邮件等)的帐户页面。).图 7-8 显示了这样一个用户。因为很难准确记住每个角色做什么,所以页面在底部显示实际的权限,这是由一个连接user_role
和role_permission
表的简单查询产生的。(因为permission
是一个自然主键,所以没有必要也连接到permission
表,因为实际的权限字符串直接出现在role_permission
表中。)
图 7-8 。管理员访问用户表
概括一下谁做什么:权限是由应用开发人员和任何可以创建或修改查询的人定义的。应用管理员定义角色并将角色与用户相关联。
用访问类实现 RBAC
我已经展示了如何管理与 RBAC 相关的数据库表。现在我将展示如何实现一个Access
类来控制对与权限相关的任何资源或操作的访问。
Page
类实例化一个单独的Access
对象,下面一行添加到页面构造函数的末尾,如清单 5-22 (也在这里,在清单 7-5 )所示:
$this->ac = new Access($this->db);
传入了对DbAccess
对象的引用,因此Access
实例不必实例化自己的实例。
当用户登录时,他或她的权限通过清单 7-4 中的Access::load_permissions
方法存储在$_SESSION
数组中。
清单 7-4 。访问构造函数和 Access::load_permissions 方法
class Access {
protected $db;
function __construct($db) {
$this->db = $db;
}
function load_permissions() {
if (isset($_SESSION)) {
$_SESSION['permissions'] = array();
$stmt = $this->db->query('select permission from
user_role join role_permission using (role)
where userid = :userid',
array('userid' => $_SESSION['userid']));
while ($row = $stmt->fetch())
$_SESSION['permissions'][$row['permission']] = 1;
}
}
...
}
对Access::load_permissions
的调用是在Page::login_phase2
的结尾,我在第六章中展示过。这是修改后的版本。
protected function login_phase2() {
$_SESSION['userid'] = $_SESSION['userid_pending'];
unset($_SESSION['userid_pending']);
$this->ac->load_permissions();
}
如果用户的角色或与角色相关联的权限发生更改,用户必须再次登录以更新其权限。
您可以使用方法Access::has_permission
在应用中的任何地方加入权限检查,该方法只检查$_SESSION
数组。
function has_permission($permission) {
return isset($_SESSION['permissions']['admin']) ||
isset($_SESSION['permissions'][$permission]);
}
每次定义新权限都要更新admin
角色,太麻烦了,所以任何拥有admin
权限的用户都自动被赋予所有权限。否则,作为参数给出的特定权限必须在$_SESSION['permissions']
数组中。
例如,在成员表单中,您可能需要编辑成员数据的member-edit
权限,但只需要查看成员数据的member-view
权限。一种快速的方法是不显示保存按钮,除非用户有适当的权限(只显示部分表单代码)。
...
$f->date('since', 'Member Since:', false);
if ($this->ac->has_permission('member-edit'))
$f->button('action_save', 'Save');
$f->end();
记住,作为应用开发人员,您所要做的就是定义权限member-edit
(如果还没有定义的话),然后放入代码来检查它,如图所示。角色及其与权限和用户的关联完全取决于应用管理员,在开发应用时,您根本不必处理这些事情。这只是 RBAC 使用起来如此方便的原因之一。
通常,您会发现您需要的权限粒度可以通过将权限与每个页面相关联来实现。这很容易处理,通过添加另一个参数,一个字符串或数组,到清单 5-22 中显示的Page
构造函数。修改后的构造函数在清单 7-5 中。
清单 7-5 。修订的页面构造函数
class Page {
protected $title, $want_session, $permissions, $db, $incl_dir, $error;
function __construct($title, $want_session = true,
$permissions = null, $incl_dir = 'incl') {
$this->title = $title;
$this->want_session = $want_session;
$this->permissions = $permissions;
$this->db = new DbAccess();
$this->incl_dir = $incl_dir;
$this->error = new Error();
$this->ac = new Access($this->db);
}
...
}
例如,MyPage
的成员页面实例化现在看起来如下:
$page = new MyPage('Member', true,
array('member-edit', 'member-view'));
另一个例子:我在本章开始时展示的查询页面被实例化为
$page = new MyPage('Queries', true, 'query');
因此,用户需要query
权限来创建或运行查询。应用管理员可能会实现并分配一个角色,将query
权限限制在很少的几个用户,因为查询允许访问数据库中的任何数据,但不能修改。
存储了所需的权限后,方法Page::go
只需要一行额外的代码来检查它们,如果用户没有所需的权限,就抛出一个异常。
$this->ac->check_permissions($this->permissions);
清单 7-6 中的给出了Access::check_permissions
的实现。同样,admin
权限导致检查成功。否则,参数给出的每个权限都必须在$_SESSION['permissions']
数组中。
清单 7-6 。access::check _ permission 方法
function check_permissions($permissions) {
if (isset($_SESSION['permissions']['admin']))
return;
if (isset($permissions)) {
if (!is_array($permissions))
$permissions = array($permissions);
foreach ($permissions as $p)
if (empty($_SESSION['permissions'][$p]))
throw new \Exception("You don't have permission
to access this page");
}
}
访问层级
这就是实现 RBAC 所需要的一切。一些表单允许应用管理员建立角色,并且只需几行代码就可以将权限与应用中需要保护的每个部分相关联。
现在,从大到小回顾一下 access 的整个层次结构是很有用的。
- 服务器超级用户(root)登录是最强大的一种访问。
- 通过 SFTP(安全文件传输协议)的文件更新访问允许任何程序文件被修改。
- 数据库管理访问允许完全的数据库权限,包括创建或删除表或其他对象,以及查看或修改任何数据。(参见第四章中的“数据库安全”一节。)
- 数据库应用访问允许查看或修改数据,这是应用通常使用的。
- 用户可以登录系统。
- 已登录的用户被分配到角色,这些角色将他们限制到某些权限。
层次结构中的每一级都提供较低级别的所有特权。例如,超级用户可以读写所有文件,包括 PHP 或其他程序,甚至可以更改服务器软件。SFTP 登录允许访问数据库,因为程序可以读取和显示数据库登录和密码。具有应用访问权限的数据库用户可以修改user
、role
和permission
表。
在“通用报告页面”一节中,我将使用 RBAC 系统实现一个报告页面,该页面自动限制用户只能运行他们有权运行的报告。如果没有 RBAC,这种级别的控制将必须由应用代码本身来执行,这将是一个维护的噩梦,因为用户总是来来去去。有了 RBAC,这完全不成问题,尤其是因为实施策略是应用管理员的责任,而不是开发人员。
报表类 : HTML 和 CSV 输出
现在回到我打开这一章的查询页面。回想一下,它为自己调用了DbAcess::query
,但是随后将结果PDOStatement
传递给了Report::html
,后者获取这些行并将它们格式化为 HTML:
$r = new Report();
$r->html($title, $stmt);
另外两个报告目的地也很有用。
- 逗号分隔值(CSV) 文件,可由任何电子表格应用和大多数数据库、邮件合并工具等读取,以及
- PDF,适合直接显示、传输到电子阅读器或打印。
Report::csv
和Report::pdf
方法处理这些输出目的地。我将在这一部分解释第一个,在我解释了如何从 PHP 程序中编写 pdf 之后再解释Report::pdf
。
报表::html 方法
清单 7-7 展示了Report::html
是如何工作的。您从一个查询中传入报告标题和PDOStatement
,并可选地传入一组列标题。如果不提供标题,该方法将使用列名本身。获取行并将列值放入 HTML table
非常简单,特别容易,因为浏览器会完成计算表格列宽度的所有困难工作。
清单 7-7 。Report::html
方法
class Report {
function html($title, $stmt, $headings = null) {
$ncols = $stmt->columnCount();
if (is_null($headings))
for ($i = 0; $i < $ncols; $i++) {
$meta = $stmt->getColumnMeta($i);
$headings[] = $meta['name'];
}
echo "<p style='font-weight: bold;'>$title</p>";
echo "<table border=1 cellpadding=5 cellspacing=0
style='border-collapse: collapse;'>";
echo "<tr>";
foreach ($headings as $h)
echo "<th>" . htmlspecial($h);
while ($row = $stmt->fetch()) {
echo "<tr>";
foreach ($row as $v)
echo "<td>" . htmlspecial($v);
}
echo "</table>";
}
...
}
关于字符集
到目前为止,我已经确保数据库和所有表单都处理 UTF-8 字符集,您可能已经注意到了指定该字符集的各种属性,比如在这个 HTML 中启动一个表单。
<form action=query.php method=post accept-charset=UTF-8>
众所周知,用所谓的拉丁字符集(如 ISO-8859-1)编码的 8 位(单字节)字符只能处理少数非英语字符。有两种方法可以处理所有常用的国际字符:宽字符,通常是 16 位(两个字节),或多字节字符,每个字符从 1 到 4 个字节不等。最流行的多字节编码是 UTF-8,这是 PHP/MySQL 应用应该使用的。
除了记住 UTF-8 字符不一定只有一个字节的宽度,你很少需要知道关于它的任何其他事情。特别是,实际使用的编码对大多数 PHP 应用来说并不重要,因为除了偶尔寻找单字节标点符号之外,很少处理单个字符。
例如,假设您要在逗号上拆分一个 UTF 8 编码的姓名,该逗号用于分隔姓和名,如“rner,MnS”您可以逐字节扫描字符,即使其中一些字节是多字节字符的一部分,也要查找逗号。逗号由单个字节表示,其他字节(甚至是多字节字符的一部分)都没有该值,因此逗号的位置是正确的。逗号前的字节正确构成“rner”,逗号后的字节正确构成“MnS”所以,大多数时候使用 UTF-8 字符串的 PHP 程序员甚至没有意识到这一点。
问题不在于单个角色;这是通过 UTF-8 字符串周围没有得到他们的破坏。如果您为每个表和 PDO 界面指定 UTF-8,MySQL 就很好,网页也是如此。问题是输出 CSV 文件和 pdf 文件,这就是为什么我直到现在才打扰你。
对于 CSV 来说,用 UTF-8 写它们很容易,而且,如果你不做任何特别的事情,那就会发生。更确切地说,问题是微软 Excel,CSV 最受欢迎的目标,不能处理 UTF-8。(在其“获取外部数据”对话框中有一个“Unicode 6.1 UTF-8”选项,但它用下划线替换了它不理解的国际字符。)
你可以接受 Excel 对 UTF-8 的处理,或者把 UTF-8 转换成 Excel 能处理的东西,也就是说选择一种特定的 8 位编码。由于你通常不知道 UTF-8 字符串是什么语言(CWA 得到了来自世界各地的小组成员),没有 8 位编码将工作。例如,如果您知道所有的 UTF-8 字符串都是匈牙利语,您可以选择 ISO 8859-2 语,但您很少遇到这种情况。(我将在下一节展示如何以及在哪里进行转换。)
另一种选择是不使用 Excel。其他电子表格,如 Apple Numbers 或 Apache OpenOffice,可以很好地处理 UTF-8(一个便宜,另一个免费)。
报告::csv 方法
在清单 7-8 中显示的Report::csv
,与Report::html
非常相似,但是有两个关键的区别:它必须将其输出写到一个文件中,并为用户提供下载它的方法,它必须处理字符集转换问题。
清单 7-8 。Report::csv
方法
function csv($stmt, $convertUTF8 = false) {
$dir = 'output';
$output_file = "$dir/" . date('Y-m-d') . '-' .
uniqid() . '.csv';
$output = fopen($output_file, "w");
$ncols = $stmt->columnCount();
for ($i = 0; $i < $ncols; $i++) {
$meta = $stmt->getColumnMeta($i);
$headings[] = $meta['name'];
}
$have_header = false;
while ($row = $stmt->fetch()) {
if (!$have_header) {
fputcsv($output, array_keys($row));
$have_header = true;
}
if ($convertUTF8) {
$r = array();
foreach ($row as $v)
$r[] = iconv('UTF-8', 'ISO-8859-1//TRANSLIT', $v);
fputcsv($output, $r);
}
else
fputcsv($output, $row);
}
fclose($output);
echo "<p>File to download:
<a href='$output_file'><b>$output_file</b></a>";
echo "<p>(Control-click or right-click and choose
\"Save Link As...\", \"Download Linked File\",
or equivalent.)";
}
如果调用者需要的话,转换是由行来处理的
$r[] = iconv('UTF-8', 'ISO-8859-1//TRANSLIT', $v);
ISO-8859-1//TRANSLIT
表示 UTF-8 字符被转换为 ISO-8859-1,没有映射到任何内容的字符被其他字符替换,并且您无法控制这种替换。结果文件将是纯 8 位 ISO-8859-1,因此任何接收应用都可以导入它,即使它不如 Excel 宽容,它至少可以用下划线代替它不知道的字符。
输出文件被写入目录output
。该目录和其中的文件将只由 web 服务器写入,因此只有该用户需要具有读或写权限。尽管如此,只有请求 CSV 文件的用户才能访问它,所以它的名称包含了一个由 PHP 函数uniqid
返回的惟一 id。这产生了如图图 7-9 所示的路径;用户可以通过点击链接来下载文件,然后在本地做他或她想做的任何事情。在服务器上一点用都没有。服务器管理员有时会想要删除旧文件,这不会自动发生。
图 7-9 。Report::csv 方法的 HTML 输出
查询页面不使用Report::csv
;一个更复杂的报告页面,我将在“通用报告页面”一节中展示。(这就是示例输出的来源。)
如果你不知道,这是一个 CSV 文件(来自 CWA 数据库)的样子。
3203,2006-04-12,10:30:00,12:00:00,"China on the Brink"
3300,2006-04-12,11:00:00,12:30:00,"Small Town vs. Big City Careers"
3302,2006-04-12,11:00:00,12:30:00,"The Healing Power of Story"
3304,2006-04-12,11:30:00,13:30:00,"Party of Poets"
3400,2006-04-12,12:00:00,13:00:00,"Metro Children Matter"
从 PHP 生成 pdf
与运行在 Mac OS、Windows 或 Linux 系统上的本地应用不同,运行在服务器上的 PHP/MySQL 应用不能直接访问打印机。相反,他们生成一个 PDF,可以下载(像 CSV 文件)和打印,或以许多其他方式使用。例如,几乎所有的电子阅读器都可以浏览 pdf 文件。
HTML 页面也可以打印,甚至可以在电子阅读器上浏览,但是,由于没有对页面布局的精确控制,结果将是随意的。
关于 PDF 和 PDF 库
PDF 代表“可移植文档格式 ”,尽管每个人都知道它们是 PDF。它们是 Adobe 在 20 世纪 90 年代早期发明的,目的是将 Adobe 大约十年前发明的页面布局语言 PostScript 的一个子集合并到一种可以通过电子邮件发送、存储在磁盘上,当然还可以打印的文件格式中。随着时间的推移,pdf 已经发展到不仅仅是记录页面布局;它们可以包括表单、执行工作流规则,甚至运行 JavaScript。
完整的 pdf 包含了 Adobe 为其设计的所有功能,当然可以用 PHP 程序编写,但要做到这一点,你需要一个功能完整的库。最明显的方法是使用标准的 PDF 扩展,它与第三方库 PDFlib 接口。服务器版本的价格高达 1100 美元。我没用过 PDFlib,不能说有多全;当然,它处理页面布局部分,这是一个报告所需要的。以他们收取的价格,我希望它能做得更多。
实际上,生成 PDF 并不困难,因为它只是嵌入在定义良好的文件结构中的 PostScript。抱着这个简单的想法,Olivier Plathey 开发了完全免费的 FPDF 库,完全用 PHP 编写,只用了大约 1800 行代码。许可证上写着:“你可以自由地将它嵌入到你的应用中(商业或非商业),修改与否都可以。”你可能会认为,与昂贵的 PDFlib 相比,FPDF 是相当有限的,但是,对于页面布局,你就错了。事实上,如果有的话,PHP/MySQL 应用需要做的事情几乎没有一件是 FPDF 不能处理的。
另一个免费的库,TCPDF,是 FPDF 的扩展,大部分是向上兼容的。对于这两个库都可以做的事情,TCPDF 运行速度慢 7 到 10 倍,包含的 PHP 代码多 25 倍,所以 FPDF 肯定是你想要的。如果在某个时候你决定你需要 TCPDF,你可以从 FPDF 转移到它,只需要几个小时就可以解决两者之间的一些不兼容问题。
事实上,我发现 TCPDF 相对于 FPDF 的大多数增强特性也可以由后者提供,只需添加在fpdf.org
网站上的脚本。它比这更深入:添加到 TCPDF 中的许多特性都是基于这些脚本的,在源代码中应该归功于脚本的作者。这是从 FPDF 开始并根据需要添加来自fpdf.org
的脚本的更强有力的理由,这正是 TCPDF 的开发者 Nicola Asuni 所做的。(自那以后,他走得更远了。)
我第一次发现 FPDF 是在我负责从为 Richardson(德克萨斯)学区开发的 Rgrade 应用中输出成绩单的时候。学区不想为商业库买单,所以我试着去了 FPDF。由于它只有 1800 行代码,我认为它只是一个真正库的 PHP 接口,显然是用 C++或其他工业级语言编写的。奇怪的是,我很好地输出了我的 pdf,但是我没有下载或安装它调用的核心库。惊喜!没有这样的库——FPDF 是独立的。当我更多地思考 pdf 是什么的时候(我在 80 年代学过 PostScript),一切都有了意义。一个罕见的少给(更确切地说是少给)和多给的例子。
自从升级以来,我已经在几个应用中使用了 FPDF,取得了巨大的成功。这是为数不多的从未给我带来麻烦的第三方库之一,主要是因为 Olivier Plathey 不管它。他不时更新网站并回答用户的问题,但他已经两年没有更改主文件了,上一次更改是在那之前的三年。当你做对了,要做的就是停下来。
一个简单的 FPDF 例子
关于 FPDF 的文档非常少,我在 2012 年想到,这样一个伟大的库应该有一本关于它的书,所以我写了一本,叫做用 PHP 和 FPDF 生成 PDF(和 TCPDF) ,你可以在亚马逊上找到这本书的电子书。在你现在正在阅读的书中,我将简要介绍你如何使用 FPDF,这将帮助你以fpdf.org
. I’ll also explain all the FPDF functions that I’ll be using for the
Report::pdf的方式阅读在线文档。如果你想要更多,你可以去我的 FPDF 书。
要使用 FPDF,首先用几个参数实例化
FPDF`类,告诉它您想要的方向(纵向或横向)、您想要使用的单位(毫米、磅等)。),以及页面大小,可以是名称,如“A4”或“letter”,也可以是宽度和高度维度的数组。例如,如果您想要一个法定大小的横向页面,并且想要以磅为单位工作,请执行以下操作:
$pdf = new FPDF('L', 'pt', 'legal');
再添加四行,你就有了一个完整的生成 PDF 的程序,如图 7-10 所示。
$pdf->SetFont('Times', '', 50);
$pdf->AddPage();
$pdf->Text(100, 200, 'Hello World!');
$pdf->Output();
图 7-10 。Hello-World PDF 示例的输出
第一行设置字体,这是必需的,因为没有默认值。接下来,你要开始新的一页;没有假设的新一页。然后在位置 x = 100,y = 200 (y 自上而下)写一些文本,然后将 PDF 直接输出到屏幕上,这只有在还没有写输出的情况下才起作用(像 PHP header
函数),这里就是这种情况。这是最简单的你好世界 FPDF 程序。
FPDF 绘画方法
除了文本,您还可以使用FPDF::Image
、FPDF::Line
和FPDF::Rect
方法绘制图像、线条和矩形。用FPDF::SetLineWidth
、FPDF::SetDrawColor
和FPDF::SetFillColor
设置绘图属性。
清单 7-9 显示了所有这六种方法,它们生成了如图图 7-11 所示的输出。以下是一些关于代码的注释:
- 构造函数中的页面大小是 5x 6 英寸,以磅为单位指定为尺寸的
array
,因为这是指定的单位。 - 在对
FPDF::SetDrawColor
的第一次调用中,颜色以 0 到 255 范围内的 RGB(红、绿、蓝)值给出。在对该方法和FPDF::SetFillColor
的后续调用中,给出了一个值,它设置了所有三个值——换句话说,一个灰度颜色。 - 前面提到过,原点在左上方,x 向右,y 向下。
清单 7-9 。FPDF 属性和绘制方法
$pdf = new FPDF('P', 'pt', array(5 * 72, 6 * 72));
$pdf->AddPage();
$pdf->SetLineWidth(2);
$pdf->SetDrawColor(50, 50, 50);
$pdf->SetFillColor(220);
$pdf->Rect(50, 150, 100, 100, 'DF');
$pdf->SetLineWidth(6);
$pdf->SetDrawColor(190);
$pdf->Line(30, 30, 300, 400);
$pdf->Image('incl/logo.png', 60, 160);
$pdf->Output();
图 7-11 。来自清单 7-6 的输出
我所展示的是一种制作艺术品的粗糙方法。组织代码来绘制类似蝴蝶俱乐部的信头会更有用。然后,很容易将 FPDF 呼叫与数据库查询结合起来生成套用信函。
首先是一个绘制页面的函数,清单 7-10 中的setup_page
。它没有做清单 7-9 没有做的事情,只是做了更多。顶部的一个棘手的问题是,在 FPDF 没有直接的方法来获得页面大小(在 TCPDF 中有)。作为一种变通方法,您可以将 x 位置设置为刚好在页面的右边距内(-1
参数),然后将1
添加到该位置以获得宽度。页面高度同上。
你会看到对FPDF::SetFont
的两个调用中的第二个参数是一个样式:B
、I
、BI
,或者一个空字符串。
清单 7-10 。绘制信头的功能
function setup_page($pdf, &$margin_left, &$margin_top,
&$height, &$width) {
$pdf->AddPage();
$pdf->SetX(-1);
$width = $pdf->GetX() + 1;
$pdf->SetY(-1);
$height = $pdf->GetY() + 1;
$pdf->SetFillColor(220);
$pdf->Rect(0, 0, $width, $height, 'F');
$inset = 18;
$pdf->SetLineWidth(6);
$pdf->SetDrawColor(190);
$pdf->SetFillColor(255);
$pdf->Rect($inset, $inset, $width - 2 * $inset,
$height - 2 * $inset, 'DF');
$margin_left = $inset + 20;
$margin_top = $inset + 20;
$pdf->Image('incl/logo.png', $margin_left, $margin_top);
$x = $margin_left + 50;
$pdf->SetFont('Helvetica', 'BI', 16);
$pdf->SetTextColor(100);
$pdf->Text($x, $margin_top + 20,
'Front Range Butterfly Club');
$pdf->SetFont('Helvetica', 'I', 9);
$pdf->SetTextColor(180);
$pdf->Text($x, $margin_top + 32,
'220 S. Main St., Anytown, CA 91234, 800-555-1234');
$pdf->SetLineWidth(1);
$pdf->Line($margin_left, $margin_top + 45,
$width - $margin_left, $margin_top + 45);
$pdf->SetFont('Times', '', 10);
$pdf->SetTextColor(0);
}
接下来,一个简单的循环,在清单 7-11 的中,生成如图 7-12 的所示的套用信函,在每一页的开始调用setup_page
。仅写入占位符文本;我将在下一节展示如何生成格式正确的段落。
清单 7-11 。循环生成套用信函
$db = new DbAccess();
$pdo = $db->getPDO();
$pdf = new FPDF('P', 'pt', array(5 * 72, 6 * 72));
$stmt = $pdo->query('select * from member
order by last, first');
while ($row = $stmt->fetch()) {
setup_page($pdf, $margin_left, $margin_top, $height, $width);
$pdf->Text($margin_left, $margin_top + 80,
"[letter to {$row['first']} {$row['last']}");
}
$pdf->Output();
图 7-12 。前四个套用信函,每个都在单独的页面上
FPDF::多池法
使用FPDF::text
方法将文本段落写入 PDF 确实很笨拙,因为它要求您指定您绘制的每个字符串的位置,并且它不知道如何将文本换行。但是FPDF::MultiCell
方法可以。
MultiCell($width, $lineheight, $text [, $border [, $align [, $fill ]]])
论据如下:
$width
:列宽。到达右边界的文本会换行到下一行。$lineheight
:每行的高度。比字体大一点或两点效果很好。$text
:要写入的文本。$border
:0 表示无边框(默认),或者一个或多个字母L
、T
、R
和B
,表示您想要的边框。参数LR
将设置左边和右边的边界,但不是顶部或底部。参数 1 与LTRB
相同,或者是一个完整的帧。用FPDF::SetLineWidth
设定边框线宽度。$align
:L
、C
、R
或J
中的一种,用于左对齐、居中对齐、右对齐或全对齐(默认)。$fill
:填充用true
或false
(默认)。用FPDF::SetFillColor
设置填充颜色。
当前位置刚好在绘制的最后一行的下方结束,因此紧随其后的 FPDF:: MultiCell
将堆叠在它的正下方。
所以现在可以打印一个实际的套用信函,如清单 7-12 中的所示,第一个字母如图 7-13 中的所示。注意在清单 7-12 中,正文在这里被换行,所以它适合本书,但不适合代码,因为文本中的任何换行符都会导致换行符。其他换行符被明确地插入到文本中以隔开字母;这些在本书中显示为空白行或者是一种逃避。
清单 7-12 。生成套用信函
$body = <<<EOT
If you haven't heard, our Spring 2013 Meadow Adventure is scheduled for Saturday, June 22\. We'll meet at the Caribou Ranch trailhead, about 2 miles north of Nederland (make a sharp left at CR 126). Make sure you're ready to go at 9 AM. Bring the usual gear, and don't forget rainwear.
See you on the 22nd!
Regards,
Tom Swallowtail,
FRBC Event Coordinator
EOT;
$db = new DbAccess();
$pdo = $db->getPDO();
$pdf = new FPDF('P', 'pt', array(5 * 72, 6 * 72));
$stmt = $pdo->query('select * from member
order by last, first limit 2');
while ($row = $stmt->fetch()) {
$text = date('F j, Y') .
"\n\nDear {$row['first']} {$row['last']}:\n\n$body";
setup_page($pdf, $margin_left, $margin_top, $height, $width);
$pdf->SetXY($margin_left, $margin_top + 80);
$pdf->MultiCell($width - 2 * $margin_left, 12, $text, 0, 'L');
}
$pdf->Output();
图 7-13 。套用信函
和 FPDF 一起写桌子
对于表格报表,用FPDF::MultiCell
将文本设置成列太麻烦了。您想要更加自动化的东西,它获取数据行,计算每行应该有多高(取决于它包含的数据),并知道何时开始新的一页。自动页眉和页脚也不错。
从盒子里出来的 FPDF 并没有完全正确的东西。FPDF 的开发者 Olivier Plathey 贡献了一个名为“多单元格表格”的脚本,你可以在他的网站(fpdf.org
), which almost does the job. The problem is that the spacing of lines that wrap (within table cells) is too great, making the table look sloppy. I’ve modified his script to fix that, packaged as a subclass of FPDF called
PDF_MC_Table)上找到,你可以在 Apress 网站(
www.apress.com)的源代码/下载区找到。在我的例子中,新方法叫做
PDF_MC_Table::RowX。它唯一的参数是一个列值数组(带有数字下标);每行调用一次。
`您需要在输出行之前设置表,通过一系列调用来设置列标题和宽度、填充等等。
SetWidths($widths_array)
SetAligns($alignments_array)
SetStyles($styles_array)
SetHorizontalPadding($hp)
SetVerticalPadding($vp)
参数$widths_array
是一个列宽数组;它应该具有与传递给PDF_MC_Table::RowX
的行数组相同数量的元素,如下例所示:
$pdf->SetWidths(array(25, 18, 18, 25, 14, 49));
参数$alignments_array
是一个对齐字母数组,与 FPDF::MultiCell 使用的对齐字母相同。
$pdf->SetAligns(array('R', 'C', 'C', 'C', 'C', 'L'));
参数$styles_array
是列文本的字体样式数组。样式是FPDF::SetFont
: B
,I
,BI
使用的样式,或者是一个空字符串。
最后,您可以通过两个填充调用来调整单元格内的填充。(与宽度一样,单位是您在构造函数中设置的值。)
清单 7-13 中的代码生成一个使用PDF_MC_Table::RowX
的成员目录,以及设置列宽和垂直填充的调用。有两点需要注意。
- 要实例化的类是
PDF_MC_Table
,不是FPDF
。 PDOStatement::fetch
有一个参数PDO::FETCH_NUM
来获取带有数字下标的行,而不是列名(默认),这是PDF_MC_Table::RowX
想要的。
清单 7-13 。输出柱状报告
$db = new DbAccess();
$pdo = $db->getPDO();
$pdf = new PDF_MC_Table('P', 'pt', 'letter');
$pdf->SetFont('Helvetica', '', 10);
$pdf->SetWidths(array(72, 72, 100, 72, 36));
$pdf->SetVerticalPadding(5);
$pdf->AddPage();
$stmt = $pdo->query('select last, first, street, city, state
from member order by last, first');
while ($row = $stmt->fetch(PDO::FETCH_NUM))
$pdf->RowX($row);
$pdf->Output();
这段代码生成了一个包含许多页面的 PDF。图 7-14 只显示了一页的一部分。请注意,行高各不相同。
图 7-14 。表格报告
FPDF 页眉和页脚
您可以通过子类化FPDF
(或PDF_MC_Table
)和重写方法FPDF::Header
和/或FPDF::Footer
来输出每页的页眉和页脚。在方法内部,使用普通的 FPDF 调用来输出您希望页眉或页脚包含的任何内容。
在您对FPDF::Header
的覆盖中,无论您离开哪里,y 位置都决定了页面主体的开始位置;换句话说,这就是割台高度。在您的FPDF::Footer
覆盖中,您从发生分页的 y 位置开始。在这两种情况下,你有责任保持在页眉或页脚区域。
您通过调用FPDF::SetAutoPageBreak.
来设置分页符位置
SetAutoPageBreak($auto [, $bottom_margin])
第一个参数(true
或false
)决定是否打开自动分页符;如果是false
,每一页都要自己调用FPDF::AddPage
。第二个参数是页脚的高度,这是你的覆盖of FPDF::Footer
开始绘制的地方。
在“报表类:PDF 输出”一节中,我将展示一个带有页眉和页脚的例子
更多 FPDF
除了我在这里讨论的,还有很多关于 FPDF 的东西,特别是如果你把在fpdf.org
发布的脚本计算在内的话。这里有一个我在 FPDF 书中包含的函数的列表,所以你可以感受一下那里有什么。
```php`
AcceptPageBreak Link SetKeywords
AddFont Ln SetLeftMargin
AddLink MultiCell SetLineStyle
AddPage Output SetLineWidth
AliasNbPages PageNo SetLink
Bookmark Polygon SetMargins
Cell Rect SetRightMargin
Circle RegularPolygon SetSubject
Close Rotate SetTextColor
Curve RoundedRect SetTitle
Ellipse SetAuthor SetTopMargin
Error SetAutoPageBreak SetX
Footer SetCompression SetXY
GetStringWidth SetCreator SetY
GetX SetDisplayMode StarPolygon
GetY SetDrawColor Text
Header SetFillColor Write
Image SetFont
Line SetFontSize
报表类:PDF 输出
现在你已经知道了`FPDF`和它的子类`PDF_MC_Table`,实现方法`Report::pdf`一点也不难。首先,`PDF_MC_Table`必须被子类化,以便定义页眉和页脚方法,如清单 7-14 中的所示。我已经解释了`PDF_Report::Header`使用的所有方法,除了`PDF_MC_Table::RowX`的第二个参数,它取消了边框,所以标题将出现在表格网格之上,正如你在图 7-15 中看到的,它显示了使用这个类的报告的部分输出。
***清单 7-14*** 。具有页眉和页脚定义的 PDF_Report 类
```php
class PDF_Report extends PDF_MC_Table {
protected $page_title, $page_width, $page_height, $headings;
function Header() {
$this->SetX(-1);
$this->page_width = $this->GetX() + 1;
$this->SetY(-1);
$this->page_height = $this->GetY() + 1;
$this->SetFont('Helvetica', 'B', 10);
$this->SetXY(0, PDF_MARGIN - 10);
$this->MultiCell($this->page_width, 8, $this->page_title, 0, 'C');
$this->SetY(PDF_MARGIN);
$this->SetFont('Helvetica', 'I', 8);
$this->RowX($this->headings, false);
}
function Footer() {
$this->SetFont('Helvetica', 'I', 8);
$y = $this->page_height - PDF_MARGIN / 2 - 8;
$cell_width = $this->page_width - 2 * PDF_MARGIN;
$this->SetXY(PDF_MARGIN, $y);
$this->MultiCell($cell_width, 8, date('Y-m-d H:i:s'), 0, 'L');
$this->SetXY(PDF_MARGIN, $y);
$this->MultiCell($cell_width, 8, $this->PageNo() . ' of {nb}',
0, 'R');
}
function set_headings($headings) {
$this->headings = $headings;
}
function set_title($title) {
$this->page_title = $title;
}
}
图 7-15 。Report::pdf 的输出示例。灰色椭圆表示对国际字符的正确处理
你可能已经注意到了PDF_Report::Footer
倒数第二行的奇怪符号{nb}
。如果您通过调用FPDF::AliasNbPages
来启用该特性,我将很快展示这一点,那么{nb}
将被 PDF 中的总页数所取代,当然,这在 PDF 完全编写完成之前是不知道的。(这是 FPDF 的特性,不是 PHP 的特性。)
类PDF_Report
规定用PDF_Report::set_headings
设置标题数组,用PDF_Report::set_title
设置标题。一会儿我会展示这两个方法在哪里被调用。
定义了包含页眉和页脚的类PDF_Report
,现在该是Report::pdf
自己的时候了,如清单 7-15 所示。我已经展示了大部分代码,在Report::html
或Report::csv
中,或者在我对 FPDF 如何工作的描述中。这里的一个新东西是,如果一个列宽数组没有被提供,那么可用的宽度会在有多少列之间平均分配,这就是图 7-15 中发生的事情。如果提供了除最后一个宽度之外的所有宽度,最右边的列将占据剩余的空间,这在只有一个长文本字段时非常有用,如图 7-16 中的所示,如“使用报告类构建报告”一节所示
清单 7-15 。Report::pdf
方法
function pdf($title, $stmt, $widths = null, $headings = null,
$orientation = 'P', $pagesize = 'letter') {
define('HORZ_PADDING', 2);
define('VERT_PADDING', 3);
$dir = 'output';
$path = "$dir/" . date('Y-m-d') . '-' . uniqid() . '.pdf';
$url = "http://" . $_SERVER['HTTP_HOST'] .
dirname($_SERVER['REQUEST_URI']) . "/$path";
$pdf = new PDF_Report($orientation, 'pt', $pagesize);
$pdf->set_title($title);
$pdf->SetX(-1);
$page_width = $pdf->GetX() + 1;
$pdf->AliasNbPages();
$pdf->SetFont('Helvetica', '' , 7);
$pdf->SetLineWidth(.1);
$pdf->SetMargins(PDF_MARGIN, PDF_MARGIN);
$pdf->SetAutoPageBreak(true, PDF_MARGIN);
$pdf->SetHorizontalPadding(HORZ_PADDING);
$pdf->SetVerticalPadding(VERT_PADDING);
$ncols = $stmt->columnCount();
if (is_null($headings))
for ($i = 0; $i < $ncols; $i++) {
$meta = $stmt->getColumnMeta($i);
$headings[] = $meta['name'];
}
$pdf->set_headings($headings);
if (is_null($widths)) {
$w = ($page_width - 2 * PDF_MARGIN) / $ncols;
for ($i = 0; $i < $ncols; $i++)
$widths[$i] = $w;
}
if (count($widths) == $ncols - 1) {
$n = 0;
foreach ($widths as $w)
$n += $w;
$widths[$ncols - 1] = $page_width - 2 * PDF_MARGIN - $n;
}
$pdf->SetWidths($widths);
$pdf->AddPage();
while ($row = $stmt->fetch()) {
$r = array();
foreach ($row as $v)
$r[] = iconv('UTF-8', 'ISO-8859-1//TRANSLIT', $v);
$pdf->RowX($r);
}
$pdf->Output($path, 'F');
echo <<<EOT
<p>Click below to access the report:
<p><a href='$url'>$url</a>
EOT;
}
图 7-16 。小组报告
也请注意这一行
$pdf->Output($path, 'F');
它将 PDF 发送到一个文件,而不是您通常想要的屏幕,因为这允许应用页面显示运行报告的结果,以及菜单栏和典型页面的所有其他标准部分。如果 PDF 出现在屏幕上,就不会有其他内容。
我在Report::pdf
中包含对iconv
的调用是一个提示,FPDF 不处理 UTF-8,这很遗憾,因为 PDF 当然处理。TCPDF 在这方面是一个进步。
使用报告类构建报告
从Report
类构建报告的一个简单明了的方法是以通常的方式构造一个页面,通过子类化Page
类,如清单 7-16 所示;来自 it 部门的 PDF 报告在图 7-16 中。
清单 7-16 。报告的页面
class MyPage extends Page {
protected function request() {
$f = new Form();
$f->start($_POST);
$f->radio('dest', 'Screen', 'screen');
$f->hspace(2);
$f->radio('dest', 'PDF', 'pdf', false);
$f->hspace(2);
$f->radio('dest', 'CSV', 'csv', false);
$f->text('year', 'Year:', 30, 'YYYY');
$f->button('action_report', 'Report', false);
$f->end();
}
protected function action_report() {
$hdgs = array('Number', 'Date', 'Start', 'Stop', 'Title');
$ttl = "{$_POST['year']} Panels";
$stmt = $this->db->query('select number, date_held,
time_start, time_stop, title from panelcwa
where year = :year order by number',
array('year' => $_POST['year']));
if ($stmt->rowCount() == 0)
$this->message('No records found', true);
else {
$r = new Report();
if ($_POST['dest'] == 'screen')
$r->html($ttl, $stmt, $hdgs);
else if ($_POST['dest'] == 'pdf')
$r->pdf($ttl, $stmt, array(50, 50, 50, 50), $hdgs);
else if ($_POST['dest'] == 'csv')
$r->csv($stmt);
}
}
}
$page = new MyPage('Panels Report', true, 'panels-view');
$page->go();
图 7-17 显示请求表单,用户可以选择输出和年份。方法MyPage::action_report
简单地调用适合请求输出的Report
方法,对于 PDF 的情况,这就是产生图 7-16 的原因。
图 7-17 。小组报告申请表
(如果你现在还不清醒,你应该想知道为什么 Front Range Butterfly Club 网站会发布一份 CWA 调查报告。这是因为蝴蝶俱乐部的副主席,一位才华横溢的 PHP/MySQL 程序员,自愿帮助 CWA 建立新的数据库。不相信我?好吧,那么也许你会相信我只是为了说明Report
类而把它放在这里,FRBC 网站是一个方便的地方。不好意思。)
一个综合报告页面
查看清单 7-16 中的,它实际上只不过是一个将输出发送给Report
类方法之一的查询。因为任何查询都可以在我在本章开始时展示的查询页面上定义,并且有一个将权限与查询相关联的菜单,所以那些简单的报告可以自动生成——没有必要为每个报告编写一个 PHP 页面。
generalized reports page
查询查询表,列出用户有权限的所有查询,以及屏幕、PDF、CSV 按钮,如图图 7-18 所示。然后,用户可以单击一个按钮,将该查询运行到输出目的地。整个程序在清单 7-17 中。注意它与清单 7-16 中的非常相似,后者只处理了一个报告。在Page
类的实例化中,不需要指定任何权限,因为它们是在代码中通过调用MyPage::request
方法的PDOStatement::fetch
循环中的Access::has_permission
显式检查的。(关于文件的东西——处理stripos($row['query'], 'file ') === 0
的代码——我一会儿会解释。)
清单 7-17 。综合报告页面
class MyPage extends Page {
protected function request() {
$stmt = $this->db->query('select * from query order by title');
while ($row = $stmt->fetch()) {
if ($this->ac->has_permission($row['permission'])) {
echo "<br>";
$this->button('Screen', array('action_run' => 1,
'dest' => 'screen', 'query_id' => $row['query_id']));
$this->button('PDF', array('action_run' => 1,
'dest' => 'pdf', 'query_id' => $row['query_id']));
$this->button('CSV', array('action_run' => 1,
'dest' => 'csv', 'query_id' => $row['query_id']));
echo ' ' . htmlspecial($row['title']);
}
}
}
protected function action_run() {
$stmt = $this->db->query('select * from query
where query_id = :query_id',
array('query_id' => $_POST['query_id']));
if ($row = $stmt->fetch()) {
if (stripos($row['query'], 'file ') === 0)
$this->transfer(substr($row['query'], 5),
array('dest' => $_POST['dest']));
else {
$stmt2 = $this->db->query($row['query']);
$r = new Report();
if ($_POST['dest'] == 'screen')
$r->html($row['title'], $stmt2);
else if ($_POST['dest'] == 'pdf')
$r->pdf($row['title'], $stmt2);
else if ($_POST['dest'] == 'csv')
$r->csv($stmt2);
}
}
else
$this->message('Query not found');
}
}
$page = new MyPage('Reports');
$page->go();
图 7-18 。“报告”页面,列出基于查询的报告
如果你回头看图 7-1 ,你会看到每个已定义的查询现在都变成了一个报告。既然已经有了查询页面,为什么还要有报告页面呢?三个原因。
- 创建和运行查询需要仅限于那些拥有
query
权限的用户。但是,这些查询创建者可能允许特权较低的用户运行查询,这可以通过设置查询的权限来实现。“报告”页面是在逐个查询的基础上实施权限的页面。 - 只有拥有
query
权限的用户才应该被允许查看任何 SQL,因为这暴露了数据库结构,而数据库结构最容易被坏人发现。 - 在报告页面上,用户可以选择目的地。当然,这本来可以添加到查询页面中,但是,由于前面的原因,并不需要添加。
简而言之,普通用户可以运行他们被允许运行的报告,但是没有query
的许可,他们不能编写甚至查看查询。
现在关于文件的事情。有些报告太复杂,无法通过简单的查询来处理。有时您需要特定于报表的 PHP,因此您需要一个单独的 PHP 文件。我称之为“文件”报告。将它们与基于查询的报告一起包含在报告页面上很方便,因此用户可以进行一站式购物。为了实现这一点,可以将查询定义为单词file
后跟文件名,如图 7-19 中的所示。
图 7-19 。文件报告的定义
query
表不关心query
列包含什么。文件报告的执行由报告页面处理,这段代码摘自清单 7-17 。
if (stripos($row['query'], 'file ') === 0)
$this->transfer(substr($row['query'], 5),
array('dest' => $_POST['dest']));
对substr
的调用只从查询值中提取文件名。一旦文件执行,它就像任何其他应用页面一样是独立的,它可以做任何它需要做的事情。它甚至不局限于使用Report
类输出。
章节总结
Select
-报表查询是简单的报表,一个允许用户定义和运行的查询页面非常方便。- RBAC 提供了一种方法来限制用户访问他们有权访问的资源和操作。
- 一个
Report
类可以处理从查询到屏幕的表格输出,比如 PDF,或者输出到 CSV 文件。 - FPDF 是一种从 PHP 程序生成 PDF 的廉价而有效的方法。
- 通用报告页面以及适当的 RBAC 策略提供了一种简单的方法来定义授权用户可以运行的报告。```
八、数据转换
我只希望他们把我的名字拼对。
——宇航员艾伦·谢泼德,谈到他希望如何被人们记住
我猜某处有人已经构建了一个数据库应用,它从一个空数据库开始,但我从未见过。总有一些预先存在的东西必须转换到新系统中,即使它只是钉在公告栏或常客文件盒上的志愿者名册。更常见的是,它还不止这些:电子表格、文件、电子邮件、文本文件,或者可能是基于 FileMaker、Microsoft Access 甚至 MySQL 的现有数据库系统。
我在本章中涉及的主题是转换在开发过程中的位置、要转换的数据源、处理问题数据(如日期和字符集)、测试和纠正以及合并变体名称(Shepard、Shephard、Shepherd)。
开发过程中的转换
当你安装新系统时,转换不仅仅是你必须做的一件讨厌的事情。它可以在开发过程中发挥有价值的作用。
提前转换
转换是我将在本书中讨论的最后一个主题,但是在开发的早期进行转换是明智的。
正如我在第四章中所建议的,一旦我设计了数据库并创建了表,我会尝试在应用本身之前开发转换,原因如下:
- 当我使用转换源时,我发现了数据库的问题,主要是缺少列和不正确的类型。有时,即使是一个关系问题,比如一对多必须改为多对多,也需要添加另一个表。
- 转换后的数据是最好的测试数据,因为它是真实的,而不是编造的,并且充满了新系统必须处理的所有怪异的情况。
- 它教会了我关于数据的知识,所以当我开始编写应用时,我更好地了解了系统。
首先开发转换的唯一缺点是,我暂时没有任何应用可以向客户演示。
转换后的数据可能不足以测试旧系统中没有的功能;为此,您必须创建测试数据。
经常转换
一旦你开发了转换程序,不要只是运行几次来测试它们。每周运行一次,或者,如果您直接连接到遗留数据库,并且不会给任何人带来负担,则每晚运行一次。那样的话,皈依日就不特别了——它只是另一天。
另一个好处是,假设旧的应用仍在使用,您的测试转换使用了一组扩展的数据,增加了在开发期间出现奇怪情况的可能性,因为有足够的时间来处理它们。
绊脚石可能是客户的 IT(信息技术)人员,他们对过去的转换有可怕的记忆,认为你每天都想这样做是疯了。当这种情况发生在我身上时,我称之为测试,这似乎很有效。当我写这篇文章时,大约是八年前了。如今我们有了现代术语持续集成 ,你可以这么称呼它。仔细想想,it 是持续集成,运行完全转换是进行持续测试的一个很好的方式。
转换源
有时有一个单一的数据源用于转换,但在我的经验中,它比那更多样。
枚举转换源
在我为 CWA 建立的系统中,我们有一个在助理协调员的 Mac 电脑上运行的 FileMaker 数据库,六个左右的 Excel 电子表格,一个必须搜索传记信息的网站,对网络服务器上照片目录的 FTP 访问,以及大量手写索引卡。
对于我为 Richardson (Texas)学区构建的年级记录本应用,我们有一个非常旧的 DB2 数据库,由州政府设立的服务机构在 IBM 大型机上运行,还有一个在 VAX 虚拟机上运行的自主开发的系统。
在这两种情况下,仅仅获得所有来源的完整列表是令人惊讶的困难。不定期更新的来源,例如年度会议的数据,可能在项目规划期间被遗忘。
更重要的是,有时一个被忽视的转换源掩盖了一个被忽视的需求。例如,一旦每个人都认识到您必须从每年创建的电子表格转换来处理小组成员的住房,问题就出现了,新系统是否应该处理住房。在开发中途发现这样的主要需求是非常具有破坏性的。不仅仅是另一份报告,这很容易。这是数据模型中的新实体!
采访处理数据的最底层人员。IT 经理会告诉你他们所有的数据都在 Oracle 中,因为这是他或她希望总裁和董事会听到的,中层经理会告诉你 Microsoft Access 上还有一些遗留数据,但秘书会告诉你一些数据在 Excel 电子表格中。(当然没有备份,但新系统会解决这个问题。)工作,直到你确定你有完整的清单。
如果不同的人对数据是否必须转换有不同意见,不要感到惊讶。我听到的对话大致是这样的:
- IT 经理:“我们不再使用那个电子表格了。所有的数据都在甲骨文里。”
- 数据输入员:“不全是。部门排名还在 Excel 里。”
- IT 经理:“这些不需要。”
- 较小的 IT 经理:“是的,他们是。用于逐年比较。”
- IT 经理:“好吧,我明白你的意思,但我们不做那些。”
- Lesser IT 经理:“我们没有,但是我们每个月都会将数据发送给学区。”
如果你关注对话,你会发现这不仅仅是一个被遗忘的转换源。它还涉及新系统是否必须提供与地区办公室的年度比较,这在你认为已经确定的要求中是没有的。
所以,在清单上工作,检查两次。
静态与动态源
在转换开发开始和转换到新系统之间,静态数据源不会改变。例子可以是关于以前的订单或过去的会议的数据。动态源作为持续业务的一部分进行更新。
对于静态源,一旦枚举了它们,您需要做的就是开始收集数据文件。你可以开发转换程序来处理它们,一旦它们工作了,你就完成了。只需运行程序来加载新的数据库。因为数据不会改变,所以即使您在开发期间每天都在运行转换,也只需要在数据库模式改变时重新转换静态数据。
对于动态源,就更复杂了。您可以开发程序并运行它们来填充数据库,但每次运行它们时,数据都会发生变化,这就增加了出现新问题的可能性。如果这发生在开发期间,这是一件好事,因为这是你想了解问题的时候。
在系统切换期间,当旧系统退役并且正在进行的操作转移到新系统时,可能会出现新的情况。任何问题都必须迅速解决,因为更新动态源必须在切换日冻结。从那时起,数据更新将与新系统一起进行,旧系统将被抛弃。问题是,“当您修复最后一分钟的数据问题时,冻结旧系统并让新系统离线会给客户带来多长时间的不便?”
你可能有将近一周的时间来进行缓慢的运作,就像一个非营利组织,除了捐助者和志愿者来来往往之外,没有什么比这更有活力的了。但是对于像网上书店这样的快速运行的书店,你没有那么多时间。
对于快速的情况,转换将是棘手的,因为您必须转换并验证一切正常的窗口很小,可能只有周日凌晨 2 点开始的几个小时。要做到这一点,需要非常仔细的计划和大量的测试。
一开始看起来很有吸引力的一个想法是并行运行新旧系统,直到新系统被证明是可行的,这样旧系统可以作为后备。但这增加了许多复杂性,更不用说那些必须更新这两个系统的人的大量额外工作了。也许你必须这样做,但我建议你尽可能避免这样做,每天进行转换是最好的方法。
直接连接到源数据库
如果转换源是一个数据库,最好使用 PHP 数据库 API 之一直接连接到它。您应该能够将 PDO 用于许多著名的数据库,比如 Oracle、Informix、PostgreSQL、SQLite,当然还有 MySQL。如果没有特定的 PDO 接口,那么就有一个通用的 PDO 接口来处理 ODBC 驱动程序,这些驱动程序通常可以从数据库供应商那里获得。(ODBC 是我在德克萨斯州做年级记录本项目时在大型机上连接 DB2 的方式。)如果您不能使用 PDO,请使用本机 API,您可以在 DB2、SQL Server、Sybase 等等中找到这种 API。
与其他 PHP 数据库 API 相比,我更喜欢 PDO 的一个原因是,它非常好地支持参数化查询,从而避免了 SQL 注入。然而,转换程序通常不会从用户那里获取数据,所以 SQL 注入并不是一个真正的问题。如果你必须使用 PDO 以外的东西,那就用吧。
与电子表格、文本文件和其他古怪的文档相比,连接到真实的数据库具有巨大的优势:日期和时间(通常)保证以标准的方式格式化,规范化(通常)消除了同一个人的多个记录等问题,并且从操作上来说,每天晚上运行转换要容易得多。
导出格式
现有的数据文件就是它们的样子,但是从数据库源中导出可以让您选择如何获取数据。
如果您不能直接连接—可能是桌面数据库,如 FileMaker—您将不得不导出数据或让您客户的员工为您导出。有时你可以选择如何导出数据;CSV(逗号分隔值)、制表符分隔值、XML 或 SQL 是最常见的选择。
在一个项目中,我从同一个 FileMaker 数据库中定期导出 CSV 文件,但是文件第一行的字段名因导出而异。我不知道为什么,做出口的人也不知道。最后,我不得不在我的转换程序中编写两个额外的特性:检测文件与文件之间不同的字段名,以及允许 CSV 文件中的字段具有几个不同名称之一的方案。这是一个烂摊子,我选择不真正解决,因为它只是为了转换,所以我只是让它尽可能防弹。我们克服了它,系统本身运行良好。教训是,在生产系统中完全不可接受的不便在转换过程中可能是可以忍受的。
自动生成转换程序
大部分转换工作涉及从转换数据构建数据库行,使用如下语句序列,类似于我编写的用于转换关于过去 CWA 小组成员的数据的程序中的语句:
$row['name_first'] = $data['Name_First'];
$row['name_last'] = $data['Name_Last'];
$row['appellation'] = $data['Appellation'];
$row['home_street1'] = $data['Home Address'];
$row['home_city'] = $data['Home City'];
...
$this->db->update('person', ..., $row);
左边的键(如name_first
)是数据库列;右边的(Name_First
)是从 FileMaker 数据库导出的 CSV 数据文件中使用的。
不用键入所有这些作业,运行一个程序来写它们是很容易的。首先,这里有一些代码来读取 CSV 文件的第一行,其中包含字段名称,并构建一个按列号索引的字段名称数组。
$path = "/Users/marc/Sites/cwadb/pastdata/Participant 2007-UTF8.csv";
$in = fopen($path, "r") or die("can't open $path");
if ($a = fgetcsv($in)) {
$k = 0;
foreach ($a as $f) {
$colname[$k] = $f;
echo "<br>{$colname[$k]}";
$k++;
}
}
fclose($in);
最初的几行输出是
Name_First
Name_Last
Appellation
Home Address
Home City
请注意,由于这只是一个实用程序,不是部署的应用的一部分,甚至不是转换的一部分,所以我在处理错误的方式上有些生涩。如果 CSV 文件打不开,我有一个对die
的调用,其他错误留给 PHP 去抱怨。
如果程序可以列出字段,它也可以输出赋值,减去它不知道的数据库列。
$path = "/Users/marc/Sites/cwadb/pastdata/Participant 2007-UTF8.csv";
$in = fopen($path, "r") or die("can't open $path");
if ($a = fgetcsv($in)) {
$k = 0;
foreach ($a as $f) {
$colname[$k] = $f;
echo "<br>\$row[''] = \$data['{$colname[$k]}']";
$k++;
}
}
fclose($in);
现在我有了可以复制粘贴到转换程序中的代码。
$row[''] = $data['Name_First']
$row[''] = $data['Name_Last']
$row[''] = $data['Appellation']
$row[''] = $data['Home Address']
$row[''] = $data['Home City']
...
这省去了大量繁琐且容易出错的打字工作。这个特殊的 CSV 文件有 56 列,我转换的其他文件有更多列。
对于大多数列,我所要做的就是在每行的空单引号之间键入适当的数据库列名。如果我不需要转换源中的列,我就删除那一行。这就是我如何得到这部分顶部显示的作业的。
清单 8-1 显示了转换程序的重要部分,基于之前写出骨架赋值的程序。
清单 8-1 。自动生成赋值的转换程序
$path = "/Users/marc/Sites/cwadb/pastdata/Participant 2007-UTF8.csv";
$in = fopen($path, "r") or die("can't open $path");
if ($a = fgetcsv($in)) {
$k = 0;
foreach ($a as $f)
$colname[$k++] = $f;
}
while ($a = fgetcsv($in)) { // for lines 2 and beyond
$k = 0;
foreach ($a as $v)
$data[$colname[$k++]] = trim($v);
$row = array();
$row['name_first'] = $data['Name_First'];
$row['name_last'] = $data['Name_Last'];
$row['appellation'] = $data['Appellation'];
$row['home_street1'] = $data['Home Address'];
$row['home_city'] = $data['Home City'];
...
$this->db->update('person', ..., $row);
}
fclose($in);
记下填充$data
数组的代码。
foreach ($a as $v)
$data[$colname[$k++]] = trim($v);
循环中的语句不是
$data[$k++] = trim($v)
因为我们想要下标的列名,而不是整数。
虽然大部分任务可以不做改动,但是有几个可能需要调整,比如那些处理日期和名字的任务,其中名字和中间名在同一个 CSV 列中。一旦做了这些调整,其中一些我将在下面的章节中讨论,转换程序就准备好了。
也就是说,如果 CSV 文件对应于单个数据库表,就可以开始了。很多情况下不会。例如,包含有关过去 CWA 面板的数据(可追溯到 1957 年)的 CSV 文件具有以下未标准化的字段集合以及其他内容(拼写错误为“Appelation 8”):
Moderator/Chairman/Presiding Appellation 7
Moderator Appellation Speaker 8
Speaker 1 Appelation 8
Appellation 1 Discussant 1
Speaker 2 Appellation d1
Appellation 2 Discussant 2
Speaker 3 Appellation d2
Appellation 3 Discussant 3
Speaker 4 Appellation d3
Appellation 4 Discussant 4
Speaker 5 Appellation d4
Appellation 5 Discussant 5
Speaker 6 Appellation d5
Appellation 6 Discussant 6
Speaker 7 Appellation d6
当您从电子表格转换时,您会一直看到这种排列,因为电子表格不支持连接。(至少,不是任何一个普通用户都能搞清楚怎么用的方式。)相反,当需要输入更多的数据时,它们会促使工作表变得越来越宽。
我的转换程序将大部分列放入panel
表,然后在person
表中为主持人和每个发言者和讨论者添加一行。然后,这些人通过多对多关系连接到他们所在的面板,这种关系涉及一个交集表。但即使在这种更复杂的情况下,我自动合成的骨骼分配也非常方便。他们甚至顺利地处理了拼写错误的列名。(事实上,直到我为这本书准备了示例代码,我才注意到它被拼错了。)
日期、时间和字符转换
很难说您会在文本文件和电子表格中发现什么,因为数据类型通常是不强制的。即使是数据库,有时规则也相当宽松。这是日期和时间的一个特殊问题。字符集之间的转换也是一个问题。
古怪的日期格式
我看到一个 4000 亿美元的数字,作为 2000 年前几年解决 Y2K 问题的总费用,如果你还记得,这些问题主要是由处理和存储两位数年份的计算机系统造成的。但是,根据我看到的转换数据,人们仍然在这样做。我看到类似于6-11
、11-12-10
、04/05
以及更糟糕的日期。
一旦将数据放入新数据库,问题就迎刃而解了,因为 MySQL 和其他所有数据库都按照严格的格式规则存储类型为date
或datetime
的列。如果您是从数据库源进行转换的,那么很有可能该列就是这样定义的,这样就没问题了。
但是,如果源是电子表格或其他文本文件,或者数据库列是文本类型,那么您就有麻烦了,因为无论谁输入日期,都可以把他或她想要的任何东西放在那里。每行的格式甚至都不一致。如果您在日期字段中看到类似“与 A/P 相同”的内容,请不要感到惊讶。
通常,如果你从文本文件转换,行数足够少,所以有人可以检查每个日期被正确转换。哪怕是几百,也值得去做。但是如果行数很大,有几千或几万行,你不可能检查每一行。你唯一的选择是对转换后的数据进行采样,并不断修改你的翻译方案,直到你确定你做对了为止。即使这样,一些日期也会转换不正确。
有两类问题:月份和日期的混淆以及两位数年份的模糊性。
通常,我看到的是以下形式之一的日期,其中 A、B 和 C 是数字:
AA-BB-CC
AA.BB.CC
AA/BB/CC
在美国,AA 通常是月份,BB 是日期,所以 2013 年 12 月 11 日应该写成 12-11-13。但是在欧洲,月和日是颠倒的,所以是 11-12-13。我不知道世界其他地方是怎么做的,这也没关系,因为一些美国人在欧洲工作,做他们习惯的事情,反之亦然,所以你真的不能依靠地理来解决这个难题。
您最多可以做到以下几点:
- 暂时假设每个文件至少与自身一致。
- 仔细观察数据,寻找不会有歧义的日期,比如 1999 年 9 月 20 日,不管怎么写,其中的 99 只能是一年,20 必须是一天,09 必须是一个月,因为其他数字都被指定了。
- 如果你所有的都是 2000 年以后的日期,年份就没用了,因为数字 01 到 12 可以是日、月或年。因此,假设最后两位数字是年份,并寻找日期和月份的组合,不能有歧义,因为这一天是 13 或更大。这将告诉你是否至少你正在看的文件的部分使用美国或欧洲惯例。
- 当你有了模式,写一个 PHP 函数来解析日期,允许分隔符是破折号,斜线,句号,或其他任何东西。
- 如果日期不完全是带有两个分隔符的六对数字,请将其记录为错误,以便以后检查。
- 当您完成了前面的所有编码后,对数据运行算法以报告您的发现。如果只有几百条记录,打印出原始数据和转换后的数据,并全部检查。如果有太多需要检查,就随机打印出几百个来检查。
你用 2000 年时使用的同样方法来处理两位数的年份:你根据上下文选择一年,该年之后的所有事情都被认为是在 20 世纪,在 21 世纪之前。例如,所有与 CWA 有关的日期都是 1948 年以后,也就是会议开始的时候,除了小组成员的生日,我们没有记录。所以如果两位数小于 48,加 2000 得到四位数年份;48 以上,加 1900。在另一个应用中,比方说一个图书数据库,您可能有 100 年或更久以前的出版日期,所以它变得更加棘手。查看是否有其他列可以提供线索,例如格式在 1985 年发生变化的书号,或者表示旧卷的收藏名称。您可能别无选择,只能手动更正日期(不是您个人,而是为您的客户工作的人)。
清单 8-2 展示了我在最近的一次转换中使用的函数convert_date
,以及一些测试用例的代码。
清单 8-2 。日期转换测试程序
test('01-02-03');
test('01-02-88');
test('02-Jan-03');
test('02-Jan-88');
test('January 2, 1988');
function test($s) {
echo "<br>$s --> " . convert_date($s);
}
function convert_date($s) {
if (empty($s))
return null;
if (preg_match("∼^(\d{1,2})-/.-/.$∼", trim($s), $m)) {
$y = $m[3] < 40 ? 2000 + $m[3] : 1900 + $m[3];
return "$y-{$m[1]}-{$m[2]}";
}
if (preg_match("∼^(\d{1,2})-/.-/.$∼", trim($s), $m)) {
$y = $m[3] < 40 ? 2000 + $m[3] : 1900 + $m[3];
$month = date('m', strtotime($m[2]));
return "$y-$month-{$m[1]}";
}
return date("Y-m-d", strtotime($s)); // can handle above, but not well defined
}
这是输出。
01-02-03 --> 2003-01-02
01-02-88 --> 1988-01-02
02-Jan-03 --> 2003-01-02
02-Jan-88 --> 1988-01-02
January 2, 1988 --> 1988-01-02
从第二次调用preg_match
开始的代码似乎是不需要的,因为strtotime
可以处理带有月份名称的日期(例如,“一月”),但是strtotime
的问题是它的行为没有精确定义。例如,它把 1957 年 1 月 2 日作为 2057 年,这是行不通的,因为 CWA 早在 1957 年就有会议。所以我包含了中间的案例以确保它被正确处理,以及与其他两个案例不匹配的strtotime
案例。这是一个有问题的决定——发出一条消息可能更好,这样我就可以跟踪数据中与这两个显式编码模式不匹配的任何日期。确保在没有针对您自己的情况进行调整的情况下,不要在您自己的程序中使用该函数。
请注意,函数convert_date
返回 YYYY-MM-DD 形式的字符串,而不是实际的日期对象。这是因为 MySQL insert
或update
语句会将格式正确的字符串正确地转换成日期。
阅读了前面的文本,您将不难理解为什么从具有类型为date
或datetime
的列的数据库转换比从文本文件转换要容易得多,也更可靠。但是,不幸的是,这种情况不会经常发生。如果您的客户已经有一个数据库,他或她可能不会要求您构建一个新的。实际情况是,通常你要处理的是电子表格和文本文件。
处理时间
就像日期一样,如果时间来自数据库,而不是来自电子表格或文本文件,就更有可能正确转换时间。像月/日/年这样的歧义是不存在的,因为每个人都同意唯一有意义的顺序是小时/分钟/秒。
检查数据以查看时间是否使用 24 小时制或具有上午/下午指示器。如果是后者,你会发现写指标的各种方式,比如10:20a
、10:20 AM
、10:20 A
等等。使用正确的正则表达式并不难处理这些问题。
到目前为止,最常见的分隔符是冒号,尽管有时会看到句号。破折号、斜线、逗号和其他字符很少出现,但请保持警惕。
最常见的问题是缺少时区指示。有时,您可以从数据库的位置推断时区。例如,一个诊所预约应用总是使用当地时区。一个更困难的例子是全球使用的在线信息系统。也许还有其他数据可以告诉你时区是什么。每个案例都不一样。
即使您有时区,也可能无法将其存储在 MySQL 数据库中。类型datetime
和time
不存储时区;类型timestamp
可以,但是在包含时区的insert
或update
语句中没有可以使用的文字。最好的方法是使用 MySQL CONVERT_TZ
函数将timestamp
从一个时区转换到另一个时区。(它对datetime
或time
值不起作用。)
如果时区很重要,就像它们对于地理数据一样,那么您最好将datetimes
以 ISO 8601 格式存储在一个文本字段中(例如1994-11-05T08:15:30-05:00
,从而完全绕过 MySQL 的时间工具。
字符转换
当人们在表单、电子表格或文本文档中输入数据时,他们通常会使用键盘似乎能做的任何事情来键入母语以外的字符。如果角色像他们期望的那样出现在屏幕上,并且像他们期望的那样打印出来,他们就很高兴。他们不知道他们用的是什么字符编码。如果事情看起来不对劲,他们就会瞎搞,直到事情变好,或者问别人如何进入角色,或者只是接受错误的角色,比如在“诺埃尔·考沃德”中而不是“诺埃尔·科沃德”
转换过程中的问题是将转换源中的字符编码转换成 PHP 程序和 MySQL 数据库所期望的编码。我的建议是让这个 UTF-8。将所有的 MySQL 编码选项设置为 UTF-8,将你的文本编辑器设置为 UTF-8,在 HTML 表单中使用 UTF-8,就像我在这本书里一直做的那样。
在你能翻译成 UTF 8 之前,你需要知道源代码是什么编码。这可能是输入数据的计算机操作系统使用的本地编码。例如,如果使用了 Mac,它可能是 Mac Roman。如果是 Windows 的话,很可能是 Windows 拉丁语 1。
如果您有一个文本文件,比如 CSV,请在文本编辑器中打开它,看看国际字符看起来是否正确。如果是这样,将编辑器的编码改为 UTF-8,验证字符仍然正确,并保存文件的副本。然后使用 UTF-8 版本。
这是个简单的例子。如果在文本编辑器中查看文件时字符看起来不正确,请查看是否可以通过调整编辑器的字符编码来使它们正确显示。我使用的编辑器是 BBEdit(仅用于 Mac OS),它有一个方便的“使用编码重新打开”命令,用于测试不同的编码。试几次就足以告诉我文件是什么,几乎总是 Mac Roman、Windows Latin 1 (ISO-8859-1)或 UTF-8。然后我用 UTF-8 保存了文件的副本。另一个选择是我在 Mac App Store 上找到的价值 3 美元的实用文本编码转换器。在 Windows 上,免费的 Notepad++允许你通过从编码菜单中选择一种来轻松尝试不同的编码,这真的很方便,甚至比 BBEdit 还要好。
如果转换源文件是 UTF 8 版本,PHP 将保持它读取的字符串不变,因此来自该文件的任何字符串仍然是 UTF 8 版本。如果你已经为 UTF 8 设置了 MySQL,就像我经常做的那样,字符串可以直接进入数据库。
转换后
一旦你开发并运行了转换程序,你就可以看到可能不愉快的结果了。
测试转换后的数据
当您用转换后的数据加载数据库时,您会希望通过比较新数据库中的内容和旧系统中的内容来测试转换。有两种有效的方法可以做到这一点。
- 行的直接比较。编写一个 PHP 程序来显示包含转换数据的每个表中的行,以及转换源中的相应数据。如果您只有几百个已转换的行,请对它们进行比较。如果数量太多,随机选择 200 行左右。如果发现错误,请停止测试,修复转换程序,重新运行转换,然后重新开始测试。
- 既然你无论如何都要实现报告,你最好在转换之后,在你实现应用的主要部分之前实现它们。然后,您可以将新报告与旧报告进行比较。
如果在最初的几次转换测试中,您发现了数据库模式中的问题,请不要感到惊讶。这就是测试的全部意义!但是在几轮查找错误、进行修复和恢复测试之后,事情会平静下来,您应该能够以及格分数通过测试。
修复坏数据
假设您的转换程序检测到错误的数据,例如日期格式错误或必填字段中缺少数据(例如,性别或出生日期)。有两种处理坏数据的方法。
- 修复旧系统中的数据,在数据完好之前不接受最终转换,或者
- 继续加载数据,即使它是坏的,并在新系统中修复它。
第一种方法的缺点是,你可能不知道如何修复数据,或者你知道,但旧的系统不能很好地修复它。另一个问题是,修复所有旧数据可能需要几天或几周的时间,这会延迟转换和转换测试的完成。
第二种方法的缺点是坏数据可能不会进入数据库。像 2007-02-30 这样的日期根本不会转换,或者说即使它转换成了什么也不会转换成 2 月 30 日。
因此,这两种方法都不完美。
在某些情况下,您可以在转换期间放松验证,稍后再收紧验证。例如,所有 CWA 小组成员必须被指定为“新成员”或“老成员”,如果他们只参加一年,他们就像新成员一样。我们没有过去小组成员的信息,也不想重建它,所以我最初允许该列为空。这正是 null 的用途:表示“未知”然后,在转换之后,我将该列设为非 null,以确保所有新数据都具有所需的值。(MySQL 将新的非法 nulls 改为空字符串。)但是这种方法只在有限的情况下有效。
对于看起来部分正确的数据项,所以你不想完全抛弃它们,比如 2007 年 2 月 30 日,如果可行并且不会不必要地延迟项目,你可以尝试在旧系统中进行修复。如果您必须进行转换,您别无选择,只能将坏数据存储在另一个列中,可能是一个名为date_received_raw, as type text
的列,它位于正式列date_received
的旁边。
也许你可以看出,我的偏好是以某种方式将数据放入数据库。我喜欢让事情向前发展。
保存未转换的数据
当您和您的客户检查转换的质量时,甚至之后,您希望能够跟踪数据库中的数据是如何从其转换源到达那里的。最简单的方法是将原始转换数据放入一个列中。
清单 8-3 展示了当我从一个文本文件转换时,我是如何做的,大多数时候是这样的:在我读完每一行数据后,我把它组合成一个字符串。
清单 8-3 。将未转换的数据保存在数据库中
...
$row = array();
$row['name_first'] = $data['Name_First'];
$row['name_last'] = $data['Name_Last'];
$row['appellation'] = $data['Appellation'];
$row['home_street1'] = $data['Home Address'];
$row['home_city'] = $data['Home City'];
...
$row['conversion_data'] = conversion_data($row, basename($path));
$this->db->update ('person', ..., $row);
...
function conversion_data($row, $label) {
$s .= "$label\n\n";
foreach ($row as $k => $v)
if (!empty($v))
$s .= "$k: $v\n";
return $s;
}
conversion_data
字段的典型值是(不是他的真实地址)
Participant 2007-UTF8.csv
name_first: Dave
name_last: Grusin
appellation: Musician; Composer; Arranger
home_street1: 123 Main Street
home_city: Somewhere
这个例子只有几个简单的字段,并没有真正说明轻松访问这些数据有多重要。也许更好的例子是这个实际数据的摘录,它要复杂和神秘得多(一些个人数据被更改)。
...
Reply: Accept
ArrivalNote: LGA 745
DepartureNote: 310 LGA
ConfirmationSheet?: No
TopicsReceived?: Yes
TopicsLetterSent?: 2008-12-19
ReplyFollowup?: No
Bio_Received: Yes
Photo_Received: Yes
Thank_You_Letter_Sent?: No
Companions: 1
CompanionNames: John Smith
NeedsHousing?: Needs Housing
Primary_Phone: 303.123.4567
Note: **late tues eve. **as close to cs as poss. **same contact info** us ly photo **sent bio 2/18 changes **DIFFERENT FLIGHT FROM SMITH
PetsOK: Yes
SmokingOK: No
Gender: F
...
Note
字段根本没有被转换,我甚至不确定它是什么意思(例如,“us ly photo”),但它在新数据库中是完整的,以防我们在那里找到我们需要的东西。
必要时,如果您发现在转换到新系统几周后,转换出现了问题,那么您很有可能能够解析conversion_data
字段中的文本并纠正错误。这比试图追踪原始文件和相关文本行的位置要方便和可靠得多。
变体名称
大多数情况下,当您从电子表格、文本文件和非标准化的数据库进行转换时,您会有一个人的数据转换为不同的人,因为在不同的源中该姓名的拼写不同。您最终会得到多条记录,名字分别为“大卫·麦克米伦”、“大卫·麦克米伦”和“大卫·麦克米伦”,都是同一个人,但在数据库中有三个不同的行。这些行需要合并,这样这个人就只有一个名字,最好是正确的名字。
转换后合并
前面,我提出了一些您可能希望在转换之前修复源代码中的坏数据的原因,以及一些您可能希望在转换之后修复数据的原因。对于不同的名称拼写,after 通常是最好的。
您可以尝试在转换之前解决这些问题,但很可能很多问题仍然不正确。许多打字员不能很快分辨出“McMillen”和“MacMillen”之间的区别,所以你仍然需要清理变体。或者,您可能有一个像 CWA 的 panel archive 电子表格这样的案例,它有 66 列宽,7700 行高,几乎无法处理。试图使名字一致可能导致的意外损坏得不偿失。这是假设 CWA 办公室有人可以做这项工作,但当我开始实施 CWA 的数据库时,情况并非如此,因为 2013 年 4 月的会议计划正在紧锣密鼓地进行。
此外,有一种有效的方法来呈现不同的名称,选择最佳名称,并清理其他名称,我现在将解释这一点。
发现名称变体
我设计的这个系统的核心是一个计算两个字符串有多近的函数。PHP 有一个名为levenshtein
的内置函数,用于计算“Levenshtein 距离”。几年前,当我在 Richardson (Texas)学区学生数据库中遇到类似情况时,我使用了这个函数,但后来我发现了一个更好的函数,叫做JaroWinkler
,在 Cohen、Ravikumar 和 Fienberg 的一篇论文中有描述(“姓名匹配任务的字符串距离度量比较”),你可以在cs.cmu.edu/∼pradeepr/papers/ijcai03.pdf
找到它。我使用 Ivo Ugrina 的 PHP 实现,你可以在iugrina.com/files/JaroWinkler/JaroWinkler.phps
获得。
该功能是
JaroWinkler($string1, $string2, $toupper = false, $PREFIXSCALE = 0.1)
我保留最后一个参数,并将第三个参数设置为true
。它返回一个介于 0 和 1 之间的数字,1 表示完全匹配。
为了查看该函数做了什么,这几行
echo '<br>' . JaroWinkler('McMillen', 'MacMillen', true);
echo '<br>' . JaroWinkler('David', 'Dave', true);
echo '<br>' . JaroWinkler('apples', 'oranges', true);
echo '<br>' . JaroWinkler('watermelon', 'sharkskin', true);
制作了这个
0.96666666666667
0.84833333333333
0.64285714285714
0.54444444444444
这些数字意味着前两对名字接近,后两对不接近。自然,自动匹配并不完美,所以它必须嵌入一个系统,允许一个人做最后的决定。没有标准的数字来区分“接近”和“不接近”;视情况而定。你可以在清单 8-4 中看到我的选择。
组织数据库搜索
因为数据库中有数百个潜在的匹配,所以我按首字母对它们进行细分,这样用户就可以一次处理几个。图 8-1 显示了初始屏幕,允许用户选择要处理的字母。
图 8-1 。选择字母的初始请求屏幕
每个字母按钮导致调用MyPage::do_letter
,它通过首字母查询person
表。该方法如下所示(我将在清单 8-5 中完整展示):
protected function do_letter($letter) {
...
$stmt = $this->db->query('select person_pk, name_last,
name_first, name_middle
from person where name_last like :letterpat and
replacedby_fk is null
order by name_last, name_first, name_middle',
array('letterpat' => "$letter%"));
while ($row = $stmt ->fetch()) {
...
$this->find_matches($row['person_pk'],
$row['name_last'], $row['name_first'],
$row['name_middle'], $names, $pks);
...
}
....
}
(注意,在 CWA 数据库中,我将主键列命名为person_pk
,而不是person_id
,这是我现在的做法,因为它是一个代理键。)
如果对包含不同拼写的person
行的引用将被对优选行的引用所替换,则被替换行的replacedby_fk
列被设置为优选行的主键(person_pk
),从而不会删除任何数据,并且可以看到进行了哪些替换。这
replacedby_fk is null
select
中的测试防止考虑已经被处理的行。(在“替换外键”一节中有更多关于这方面的内容)
真正的工作是在函数find_matches
中完成的,在清单 8-4 中,它取一个名字的三个部分(姓、名和中间名)并返回两个数组:$names
包含匹配的名字,$pks
包含它们对应的主键。
清单 8-4 。MyPage::find_matches
方法
protected function find_matches($pk, $last, $first, $middle,
&$names, &$pks) {
if (strlen($last) < 2)
return;
$pfx = mb_substr($last, 0, 2, 'UTF-8');
$stmt = $this->db->query('select person_pk, name_last,
name_first, name_middle
from person where name_last like :pfxpat and
person_pk != :pk and
replacedby_fk is null order by name_last, name_first,
name_middle',
array('pfxpat' => "$pfx%", 'pk' => $pk));
while ($row = $stmt ->fetch()) {
$jw1 = JaroWinkler($last, $row['name_last'], true);
if (empty($first))
$jw2 = $jw3 = $jw4 = 1;
else {
$name1 = explode(' ', trim($first));
$name2 = explode(' ', trim($row['name_first']));
$jw2 = JaroWinkler($name1[0], $name2[0], true);
$jw3 = JaroWinkler($name1[0], $row['name_middle'], true);
$jw4 = JaroWinkler($name2[0], $middle, true);
}
if ($jw1 > .9 && ($jw2 > .75 || $jw3 > .75 || $jw4 > .75)) {
$names[] = $this->build_name($row);
$pks[] = $row['person_pk'];
}
}
}
顶部是一个select
,用于那些还没有匹配的以相同的两个字母开头的名字,我发现这对于 CWA 数据集很有效,但你可能想根据自己的情况进行调整。在进行这一点和后面描述的其他权衡时,我不想要太多的匹配——超过几百个就太麻烦了。我还希望标准足够宽松,这样我们就能得到足够多的匹配。
第一次 JaroWinkler 比较是使用传入的姓氏和每个选定行的姓氏,存储在$jw1
中。另外三个度量标准,$jw2
、$jw3
和$jw4
,用于名和中间名。如果缺少名字,这些将被设置为 1,这意味着测试将只使用姓氏。(有了这个数据,如果名字不见了,中间名的任何东西都是相当没有意义的。)
多年来,不同的人在源数据文件中输入姓名时,有时会将名字和中间名放在同一个字段中,因此两个explode
行将名字字段分开。$jw2
是名字字段中第一个单词(可能是唯一的单词)的度量。$jw3
使用selected
行的名字字段的第一个字和传入的中间名,$jw4
反之亦然。这适用于 CWA 的数据,因为小组成员经常使用他们的中间名。例如,前一年是“r·巴克明斯特·富勒”,第二年是“巴克明斯特·富勒”(如果您想知道,在源数据中,以首字母作为名字的名字总是将首字母和中间名输入到名字字段中,所以我编程的比较方式是有效的。您必须为您自己的数据结构修改进行比较的确切方式。)
无论如何,我认为如果姓的度量是. 9,任何名和中间名的度量是. 75,那么这个比较就是匹配的。如果匹配的话,名字和它的主键被存储在$names
和$pks
数组中。函数build_name
从行数据中构造一个字符串名称。
function build_name($row) {
return htmlspecial (trim(
"{$row['name_last']}, {$row['name_first']} {$row['name_middle']}"
));
}
为了了解find_matches
在实践中的作用,这里有一些实际的比赛。
Abrams, Karen
Abrams, Kevin
Adams, Tom
Adams, W. Thomas
Bakeman, Liz
Bake, Elizabeth
Bakeman, Nina Elizabeth
Elliott, Patricia
Elliot, Patricia
仅凭我的直觉,不了解历史小组成员,艾布拉姆斯和贝克匹配看起来是错误的,而其他人看起来是正确的,尽管在没有检查更多数据的情况下无法确定。汤姆·亚当斯和 w·托马斯·亚当斯可能是两个不同的人。我最终发现,莉兹·贝克曼和尼娜·伊丽莎白·贝克曼实际上是两个不同的人。稍后我将介绍您如何决定采取什么行动。首先我将解释完匹配代码。
回到主select
循环,清单 8-5 展示了整个do_letter
函数,这样你可以看到来自find_matches
的结果是如何处理的。
清单 8-5 。MyPage : : do_letter 方法
protected function do_letter($letter) {
$found = false;
$skip = array();
$stmt = $this->db->query('select person_pk, name_last,
name_first, name_middle
from person where name_last like :letterpat and
replacedby_fk is null
order by name_last, name_first, name_middle',
array('letterpat' => "$letter%"));
while ($row = $stmt ->fetch()) {
if (!in_array($row['person_pk'], $skip)) {
$names = array($this->build_name($row));
$pks = array($row['person_pk']);
$this->find_matches($row['person_pk'],
$row['name_last'], $row['name_first'],
$row['name_middle'], $names, $pks);
if (count($names) > 1) {
for ($i = 0; $i < count($names); $i++) {
$pkstring = '';
foreach ($pks as $p)
if ($p != $pks[$i])
$pkstring .= ',' . $p;
$pkstring = substr($pkstring, 1);
echo "<br>{$names[$i]}";
$this->button('Choose',
array('action_choose' => 1,
'pk' => $pks[$i],
'others' => $pkstring),
'persons_link.php', true);
$this->button('View',
array('action_detail' => 1,
'pk' => $pks[$i]),
'person.php', true);
$found = true;
}
$skip = array_merge($skip, $pks);
echo '<hr>';
}
}
}
if (!$found)
echo "<p>Letter {$letter}: No persons found.";
}
关于$skip
数组:它包含所有被处理行的主键,或者是在do_letter
中选择的,或者是由find_matches
匹配的。跳过任何已经考虑过的行,因为将“Adams,Tom”与“Adams,W. Thomas”进行匹配,然后再将“Adams,W. Thomas”与“Adams,Tom”进行匹配是多余的。在while
后面有in_array
的那一行跳过。
假设我要处理一行(不在$skip
数组中的一行),我用所选行的列值初始化$names
和$pks
数组,然后调用find_matches
。如果它找到了什么(count($names) > 1
),我会遍历这些名字,并通过两个按钮输出每个名字。
- Choose 按钮选择我们想要保留的名称,一个或多个与之匹配的名称将被替换。
- “查看”按钮显示与姓名相关的完整记录,以帮助用户决定他或她想要保留哪个姓名以及应该替换哪些相似的姓名。有时在用户准备做决定之前需要几分钟的研究。
选择按钮创建一个新的弹出窗口(true
的最后一个参数),打开到页面persons_link.php
,带有两个参数,被选择的人和潜在匹配列表。替换工作由persons_link.php
完成。
潜在匹配的列表作为参数others
的值传递,您可以在 Choose 按钮的代码中看到。因为 PHP 数组不能通过 JavaScript 直接传递给 PHP(这是传递参数的方式),所以主键列表被转换成一个字符串,其内容如下:
$pkstring = '';
foreach ($pks as $p)
if ($p != $pks[$i])
$pkstring .= ',' . $p;
$pkstring = substr($pkstring, 1);
最后一行去掉了前面多余的逗号。我发现在最后去掉它比写额外的代码来避免把它放进去更容易。(我没有用implode
是因为others
数组一定不能包含被选中的人。)
为了阐明“选择”按钮的外观,请使用名称
Alison, Mike
Allison, Michael J.
Allison, Mitchell
第一个可能有23456
作为主键,其他选项是24598,21034
,第二个有24598
和23456,21034
,第三个有21034
和23456,24598
。换句话说,三个主键中的每一个都出现在一个按钮后面,另外两个作为其他选择。图 8-2 显示了实际输出的截图。
图 8-2 。匹配的名称和按钮
如果您希望,比如说,Michael J. Allison 成为首选行,并更改对 Mike Alison 的所有引用,那么您可以单击 Michael J. Allison 旁边的选择按钮。这些参数通过 POST 发送到页面persons_link.php
。
pk = 24598
others = 23456,21034
当从 Choose 按钮调用时,文件persons_link.php
显示一个表单,这样用户可以指示哪些人应该被选中的人替换,如清单 8-6 中的所示。
清单 8-6 。MyPage::action_choose
方法
protected function action_choose() {
$others = explode(",", $_POST['others']);
$chosen_name = $this->GetNameByID($_POST['pk']);
$f = new Form();
$f->start($_POST);
$f->hidden('pk', $_POST['pk']);
echo <<<EOT
<p>Do you want this person:
<p style='margin-left:20px;'>$chosen_name
<p>to replace these checked persons?
EOT;
foreach ($others as $p)
$f->checkbox("replace[$p]", $this->GetNameByID($p));
echo <<<EOT
<p>The replaced persons will not be deleted,
so you can copy<br>any required data into the person
that replaces them.
EOT;
$f->button('action_replace', 'Replace');
echo "<button class=button type=button
οnclick='window.close();'>Cancel</button>";
$f->end();
}
传递给Form::checkbox
的字段名是replace[$p]
,这意味着当方法MyPage::action_replace
接收到表单数据时,被检查的名字将组成数组$_POST['replace']
。我将在清单 8-7 中展示如何访问这个数组。
方法GetNameByID
,没有显示,返回一个人的名字,给定主键。图 8-3 显示了显示内容的示例;用户选中了 Mike Alison,没有选中 Mitchell Allison,因为他是另一个人。
图 8-3 。选择被替换人员的表格
具有讽刺意味的是,虽然我们想要的人 Michael J. Allison 和我们想要替换的人 Mike Alison 最引人注目的一点是他们的名字不同,但就数据库模型而言,这并不是真正的问题。毕竟,很容易设计出一个人可以有不同的名字(Mike 可能是他的昵称)。回想一下模型:问题是我们有两个实体,而我们只想要一个。因此,对实体 Mike Alison 的每个引用(外键)都必须替换为对实体 Michael J. Allison 的引用。这将使模型符合只有一个 Michael j .(“Mike”)Allison 出席会议的现实。
替换外键
从代码中可以看出,Cancel 按钮只是关闭窗口。Replace 按钮将表单数据发送回persons_link.php
,由清单 8-7 中的代码处理。
清单 8-7 。MyPage : : replace 方法
protected function action_replace() {
if (empty($_POST['replace'])) {
$this->message('No replacements were checked.');
return;
}
$this->db->query("begin");
$pk = $_POST['pk'];
foreach ($_POST['replace'] as $p => $v) {
echo '<p>"' . $this->GetNameByID($pk) . '" will replace "' .
$this->GetNameByID($p) . '"';
$this->replace($pk, $p, 'donation', 'donor1_fk');
$this->replace($pk, $p, 'donation', 'donor2_fk');
$this->replace($pk, $p, 'house', 'committee_contact_fk');
$this->replace($pk, $p, 'invitation', 'invitee_fk');
$this->replace($pk, $p, 'panel', 'moderator_fk');
$this->replace($pk, $p, 'panel', 'producer1_fk');
$this->replace($pk, $p, 'panel', 'producer2_fk');
$this->replace($pk, $p, 'person', 'committee_contact_fk');
$this->replace($pk, $p, 'person', 'companion_to_fk');
$this->replace($pk, $p, 'person', 'contact_fk');
$this->replace($pk, $p, 'person', 'hyphen_fk');
$this->replace($pk, $p, 'person', 'introduced_by_fk');
$this->replace($pk, $p, 'status', 'person_fk');
$this->replace($pk, $p, 'topic', 'participant_fk');
$this->replace($pk, $p, 'trip', 'driver_arrival_fk');
$this->replace($pk, $p, 'trip', 'driver_departure_fk');
$this->replace($pk, $p, 'trip', 'participant1_fk');
$this->replace($pk, $p, 'trip', 'participant2_fk');
$this->replace($pk, $p, 'venue', 'contact_fk');
$this->link_person($pk, $p);
}
$this->db->query("commit");
$this->message('All updates were successful.', true);
}
$this->db->query("commit");
$this->message('All updates were successful.', true);
}
$_POST['replace']
数组的每个元素都是一个人的主键,在数据库中的任何地方都将被所选择的人的主键$_POST['pk’]
替换。这项工作是通过MyPage::replace
的方法完成的。九个表中有 19 个外键需要替换。最后,对方法MyPage::link_person
的调用通过replacedby_fk
字段将被替换的人链接到替换的人,因此我们可以跟踪进行了哪些替换,正如我前面解释的那样。
action_replace
函数是事务的一个很好的例子,在这种情况下,围绕所有被替换的人的所有更新。这样,如果出现错误,就可以清楚地知道数据库处于什么状态:未更改。
下面是MyPage::replace
函数。
protected function replace($pk, $p, $table, $col) {
$this->db->query("update $table set $col = :pk where $col = :p",
array('pk' => $pk, 'p' => $p));
echo "<p class=replace-msg>$table.$col updated</p>";
}
而这里是MyPage::link_person
。
protected function link_person($pk, $p) {
$this->db->query('update person set replacedby_fk = :pk
where person_pk = :p',
array('pk' => $pk, 'p' => $p));
echo "<p class=replace-msg>replaced person linked to
replacing person</p>";
}
图 8-4 显示了一些示例输出。
图 8-4 。成功更新的确认
查找外键
如何获得要替换的外键的完整列表?有三种方法可以做到这一点,假设您已经谨慎地将外键约束合并到您的表定义中,就像我一直做的那样。第一种是查看 MySQL Workbench 中的模式或您用来管理数据库的任何工具,并找到引用person.person_pk
的外键。这仅适用于小型、简单的数据库。
第二种方法是将整个模式导出为 SQL 的文本文件,并用文本编辑器扫描它。您可以使用 MySQL 工作台管理窗口上的数据导出命令。检查导出到独立文件和跳过表数据。在文本编辑器中,搜索如下行:
CONSTRAINT `constraint_donation_donor1_fk` FOREIGN KEY (`donor1_fk`)
REFERENCES `person` (`person_pk`) ON DELETE NO ACTION ON UPDATE NO ACTION,
CONSTRAINT `constraint_donation_donor2_fk` FOREIGN KEY (`donor2_fk`)
REFERENCES `person` (`person_pk`) ON DELETE NO ACTION ON UPDATE NO ACTION
你要找的是对person.person_pk
的引用。然后在文本编辑器中修改文本,直到得到您需要的对replace_person
的调用。
第三种,也是目前最好的方法,是对 MySQL 用来存储用户模式结构的信息模式运行查询。您可以使用concat
函数获得 PHP 代码形式的结果,以便直接整合到 action_replace 函数中。以下是我使用的查询:
select
concat("$this->replace($pk, $p, '", table_name, "', '", column_name, "');")
from information_schema.key_column_usage
where referenced_table_name = 'person' and
referenced_column_name = 'person_pk' and
table_schema = 'cwadb'
order by table_name, column_name
实际上,输出是
$this->replace($pk, $p, 'donation', 'donor1_fk');
$this->replace($pk, $p, 'donation', 'donor2_fk');
$this->replace($pk, $p, 'house', 'committee_contact_fk');
$this->replace($pk, $p, 'invitation', 'invitee_fk');
$this->replace($pk, $p, 'panel', 'moderator_fk');
$this->replace($pk, $p, 'panel', 'producer1_fk');
$this->replace($pk, $p, 'panel', 'producer2_fk');
$this->replace($pk, $p, 'person', 'committee_contact_fk');
$this->replace($pk, $p, 'person', 'companion_to_fk');
$this->replace($pk, $p, 'person', 'contact_fk');
$this->replace($pk, $p, 'person', 'hyphen_fk');
$this->replace($pk, $p, 'person', 'introduced_by_fk');
$this->replace($pk, $p, 'person', 'replacedby_fk');
$this->replace($pk, $p, 'status', 'person_fk');
$this->replace($pk, $p, 'topic', 'participant_fk');
$this->replace($pk, $p, 'trip', 'driver_arrival_fk');
$this->replace($pk, $p, 'trip', 'driver_departure_fk');
$this->replace($pk, $p, 'trip', 'participant1_fk');
$this->replace($pk, $p, 'trip', 'participant2_fk');
$this->replace($pk, $p, 'venue', 'contact_fk');
我删除了person.replacedby_fk
(加粗)的行,因为该列供变体名称代码本身使用。还剩 19 行。
所有这三种技术的缺点是,如果数据库以这种方式改变,您必须记住包括任何引用person.person_pk
的新外键。一个改进可能是用检索到的表和列名直接调用replace
,而不是生成 PHP 代码复制并粘贴到程序中。类似于以下内容:
$stmt = $this->db->query(
"select table_name, column_name
from information_schema.key_column_usage
where referenced_table_name = 'person' and
referenced_column_name = 'person_pk' and
table_schema = 'cwadb'
order by table_name, column_name");
while ($row = $stmt->fetch())
$this->replace ($pk, $p, $row['table_name'], $row['column_name']);
它很聪明,但是使用起来太危险了。您真的不希望完全基于信息模式上的查询结果,将外键插入到您从未见过其身份的列中。实际上,已经有一个 bug: person.replacedby_fk
因为被选中的人得到了一个非被选中的键的值,这是完全错误的,因为被选中的人应该让那个字段为 null,因为它没有被替换。(被替换人员非空。)我们可以放入一个测试来避免为该列调用replace
,但是,即使有了这个修正,这个循环运行起来还是太危险了。我只想查看打给replace
的电话。
标记替换行
我喜欢做一些事情来帮助用户理解被替换的人发生了什么。
- 当进行搜索时,我将替换人员显示为灰色。另一种方法是完全跳过它们,这很容易做到,就像我之前展示的,通过测试一个空的
replacedby_fk
字段。但是我认为向用户展示它们可以让用户确信替换操作已经执行,并且数据仍然在那里,如果需要的话。毕竟,我在这里展示的代码都没有将数据(如电子邮件或电话号码)从被替换的人复制到首选人。这太复杂了,无法自动完成。一些数据的手动复制必须作为后续任务进行。 - 如果检索到替换人员的数据,我会显示一条红色的大消息,表明该数据不再有效,并且我还将表单设为只读。这是为了避免将数据输入错误的人的行。
因此,我所展示的是一个相当复杂,但非常值得实现的半自动解决不同拼写的方法。计算机进行匹配,一旦用户做出选择,就替换外键,如果做得不完美,这真的会搞乱数据库。用户做出决策,可能是在查看了详细数据或者甚至咨询了其他来源之后。而且,正如我所说的,用户可能希望将重要数据从被替换的行复制到替换行。
章节总结
- 作为检查设计和提供测试数据的一种方式,转换应该在数据库设计之后立即进行。
- 经常转换,甚至每晚转换,为持续集成提供了持续的测试。
- 列举转换源有时非常困难,但这是必不可少的。
- 静态数据源很容易处理。对于动态源,最好直接连接到数据库。
- 日期、时间和字符编码需要小心处理,有时甚至需要技巧。
- 通过将数据或报告(如果有)与旧系统进行比较来测试转换。
- 如果可能的话,你会发现使用新系统转换坏数据并修复它很方便。
- 考虑在新数据库的文本字段中保存原始的、未转换的数据,以便可以参考。
- 使用自动和手动机制的组合,变体名称最好被合并到新的数据库中。