PHP8-解决方案-七-

96 阅读35分钟

PHP8 解决方案(七)

原文:PHP 8 Solutions

协议:CC BY-NC-SA 4.0

十五、管理内容

虽然您可以使用 phpMyAdmin 进行大量的数据库管理,但是您可能希望设置一些区域,让客户端可以登录到这些区域来更新一些数据,而不必让它们完全控制您的数据库。为此,您需要构建自己的表单并创建定制的内容管理系统。

每个内容管理系统的核心是有时被称为 CRUD(创建、读取、更新和删除)的循环,它仅使用四个 SQL 命令:INSERTSELECTUPDATEDELETE。为了演示基本的 SQL 命令,本章将向您展示如何为一个名为blog的表构建一个简单的内容管理系统。

即使您不想构建自己的内容管理系统,本章介绍的四个命令对于任何数据库驱动的页面都是必不可少的,例如用户登录、用户注册、搜索表单、搜索结果等等。

在本章中,您将了解以下内容:

  • 在数据库表中插入新记录

  • 显示现有记录的列表

  • 更新现有记录

  • 删除记录前要求确认

建立内容管理系统

管理数据库表中的内容包括四个阶段,我通常将这四个阶段分配给四个独立但相互链接的页面:一个用于插入、更新和删除记录,另一个用于现有记录的列表。记录列表有两个目的:标识数据库中存储了什么,更重要的是,通过查询字符串传递记录的主键来链接到更新和删除脚本。

blog表格包含一系列标题和文本文章,将在日本旅程网站中显示,如图 15-1 所示。为了简单起见,这个表只包含五列:article_id(主键)、titlearticlecreatedupdated

img/332054_5_En_15_Fig1_HTML.jpg

图 15-1

日本之旅网站中显示的博客表的内容

创建博客数据库表

如果您只想继续学习内容管理页面,请从ch15文件夹中的blog.sql导入表结构和数据。打开 phpMyAdmin,选择phpsols数据库,按照与第十二章相同的方式导入表格。SQL 文件创建了这个表,并用四篇短文填充它。

如果您喜欢自己从头开始创建一切,打开 phpMyAdmin,选择phpsols数据库,如果还没有选择的话,单击Structure选项卡。在Create table部分,在Name字段中输入博客,在Number of columns字段中输入 5 。然后点击Go。使用以下截图和表 15-1 : img/332054_5_En_15_Figa_HTML.jpg中显示的设置

表 15-1

博客表的列定义

|

|

类型

|

长度/值

|

默认

|

属性

|

|

索引

|

A_I

| | --- | --- | --- | --- | --- | --- | --- | --- | | article_id | INT |   |   | UNSIGNED | 取消选择 | PRIMARY | 挑选 | | title | VARCHAR | 255 |   |   | 取消选择 |   |   | | article | TEXT |   |   |   | 取消选择 |   |   | | created | TIMESTAMP |   | CURRENT_TIMESTAMP |   | 取消选择 |   |   | | updated | TIMESTAMP |   | CURRENT_TIMESTAMP | on update``CURRENT_TIMESTAMP | 取消选择 |   |   |

createdupdated列的默认值被设置为CURRENT_TIMESTAMP。所以当第一次输入记录时,两列得到相同的值。用于updatedAttributes列被设置为on update CURRENT_TIMESTAMP。这意味着每当记录发生更改时,它都会更新。为了跟踪记录最初是何时创建的,created列中的值永远不会更新。

创建基本的插入和更新表单

SQL 通过提供单独的命令,对插入和更新记录进行了重要的区分。INSERT仅用于创建一个全新的记录。插入记录后,必须使用UPDATE进行任何更改。因为这涉及到使用相同的字段,所以两个操作可以使用相同的页面。然而,这使得 PHP 更加复杂,所以我更喜欢先为插入页面创建 HTML,保存一个副本作为更新页面,然后分别编写代码。

插入页面中的表单只需要两个输入字段:标题和文章。其余三列的内容(主键和两个时间戳)被自动处理。插入表单的代码如下所示:

<form method="post" action="blog_insert.php">
    <p>
        <label for="title">Title:</label>
        <input name="title" type="text" id="title">
    </p>
    <p>
        <label for="article">Article:</label>
        <textarea name="article" id="article"></textarea>
    </p>
    <p>
        <input type="submit" name="insert" value="Insert New Entry">
    </p>
</form>

表单使用了post方法。你可以在blog_insert_mysqli_01.phpch15文件夹中的blog_insert_pdo_01.php中找到完整的代码。内容管理表单已经用admin.css赋予了一些基本的样式,它在styles文件夹中。在浏览器中查看时,该表单如下所示:

img/332054_5_En_15_Figb_HTML.jpg

除了标题和提交按钮之外,更新表单是相同的。按钮代码是这样的(完整代码在blog_update_mysqli_01.phpblog_update_pdo_01.php):

<input type="submit" name="update" value="Update Entry">

我给标题和文章输入字段起了与blog表中的列相同的名字。这使得以后编写 PHP 和 SQL 代码时更容易跟踪变量。

Tip

作为一种安全措施,一些开发人员建议使用数据库列的不同名称,因为任何人只需查看表单的源代码就可以看到输入字段的名称。使用不同的名称会增加闯入数据库的难度。在网站受密码保护的部分,这不应该是一个问题。但是,您可能希望考虑公开可访问的表单,例如用于用户注册或登录的表单。

插入新记录

向表中插入新记录的基本 SQL 如下所示:

INSERT [INTO] table_name (column_names)
VALUES (values)

INTO在方括号中,这意味着它是可选的。它纯粹是为了让 SQL 读起来更像人类语言。列名可以按照您喜欢的任何顺序排列,但是第二组括号中的值必须按照它们所引用的列的顺序排列。

虽然 MySQLi 和 PDO 的代码非常相似,但为了避免混淆,我将分别处理它们。

Note

本章中的许多脚本使用了一种称为“设置标志”的技术flag是一个布尔变量,它被初始化为truefalse,用于检查是否有事情发生。例如,如果$OK最初被设置为false,并且只有当一个数据库查询成功执行时才被重置为true,那么它可以被用作控制另一个代码块的条件。

PHP 解决方案 15-1:用 MySQLi 插入新记录

这个 PHP 解决方案展示了如何使用 MySQLi 预准备语句向blog表中插入一条新记录。使用预处理语句可以避免转义引号和控制字符的问题。它还可以保护你的数据库免受 SQL 注入病毒的攻击(参见第十三章)。

  1. php8sols站点根目录下创建一个名为admin的文件夹。从ch15文件夹中复制blog_insert_mysqli_01.php,在新文件夹中另存为blog_insert_mysqli.php

  2. 插入新记录的代码应该只在表单已经提交的情况下运行,所以它包含在一个条件语句中,该语句检查$_POST数组中提交按钮(insert)的name属性。将以下内容置于DOCTYPE声明之上:

<?php
if (isset($_POST['insert'])) {
    require_once '../includes/connection.php';
    // initialize flag
    $OK = false;
    // create database connection
    // initialize prepared statement
    // create SQL
    // bind parameters and execute statement
    // redirect if successful or display error
}
?>

包含连接函数后,代码将$OK设置为false。只有在没有错误的情况下,才会重置为true。结尾的五个注释规划了我们接下来要填写的剩余步骤。

  1. 以具有读写权限的用户身份创建一个到数据库的连接,初始化一个准备好的语句,并为将从用户输入中获得的数据创建带有占位符的 SQL,如下所示:
// create database connection
$conn = dbConnect('write');
// initialize prepared statement
$stmt = $conn->stmt_init();
// create SQL
$sql = 'INSERT INTO blog (title, article)
           VALUES(?, ?)';

将从$_POST['title']$_POST['article']中导出的值由问号占位符表示。其他列将自动填充。article_id列为主键,使用AUTO_INCREMENTcreatedupdated列默认为CURRENT_TIMESTAMP

Note

该代码与第十三章的顺序略有不同。该脚本将在第十七章中进一步开发,以运行一系列 SQL 查询,因此首先初始化准备好的语句。

  1. 下一步是用变量中保存的值替换问号——这个过程叫做绑定参数。插入以下代码:
if ($stmt->prepare($sql)) {
    // bind parameters and execute statement
    $stmt->bind_param('ss', $_POST['title'], $_POST['article']);
    $stmt->execute();
    if ($stmt->affected_rows > 0) {
        $OK = true;
    }
}

这是保护您的数据库免受 SQL 注入攻击的部分。按照您希望将变量插入到 SQL 查询中的顺序,将变量传递给bind_param()方法,同时传递指定每个变量的数据类型的第一个参数,同样按照变量的顺序。两者都是字符串,所以这个参数是'ss'

一旦值被绑定到占位符,调用execute()方法。

affected_rows属性记录了有多少行受到了INSERTUPDATEDELETE查询的影响。

Caution

如果查询触发了 MySQL 错误,affected_rows返回 1。与一些计算语言不同,PHP 将 1 视为true。因此,您需要检查affected_rows是否大于零,以确保查询成功。如果大于零,$OK复位到true

  1. 最后,将页面重定向到现有记录的列表,或者显示任何错误消息。在上一步之后添加以下代码:

  2. 在页面正文中添加以下代码块,以便在插入操作失败时显示错误消息:

    <h1>Insert New Blog Entry</h1>
    <?php if (isset($error)) {
        echo "<p>Error: $error</p>";
        } ?>
    <form method="post" action="blog_insert_mysqli.php">
    
    
// redirect if successful or display error
    if ($OK) {
        header('Location:
            http://localhost/php8sols/admin/blog_list_mysqli.php');
        exit;
    } else {
        $error = $stmt->error;
    }
}
?>

完整的代码在ch15文件夹的blog_insert_mysqli_02.php中。

这就完成了插入页面,但是在测试之前,创建blog_list_mysqli.php,这在 PHP 解决方案 15-3 中有描述。

注意为了关注与数据库交互的代码,本章中的脚本不认证用户输入。在实际应用中,您应该使用第六章中描述的技术来检查从表单提交的数据,如果发现错误,就重新显示。

PHP 解决方案 15-2:用 PDO 插入新记录

这个 PHP 解决方案展示了如何使用 PDO 预处理语句在blog表中插入一条新记录。如果您还没有这样做,在php8sols站点根目录下创建一个名为admin的文件夹。

  1. blog_insert_pdo_01.php复制到admin文件夹,并保存为blog_insert_pdo.php

  2. 插入新记录的代码应该只在表单已经提交的情况下运行,所以它包含在一个条件语句中,该语句检查$_POST数组中提交按钮(insert)的name属性。将以下内容放在 PHP 块中的DOCTYPE声明上方:

if (isset($_POST['insert'])) {
    require_once '../includes/connection.php';
    // initialize flag
    $OK = false;
    // create database connection
    // create SQL
    // prepare the statement
    // bind the parameters and execute the statement
    // redirect if successful or display error
}

包含连接函数后,代码将$OK设置为false。只有在没有错误的情况下,才会重置为true。结尾的五个注释指出了剩下的步骤。

  1. 以具有读写权限的用户身份创建到数据库的 PDO 连接,并构建如下 SQL:
// create database connection
$conn = dbConnect('write', 'pdo');
// create SQL
$sql = 'INSERT INTO blog (title, article)
VALUES(:title, :article)';

将从变量派生的值由命名的占位符表示,占位符由冒号(:title:article)开头的列名组成。其他列的值将由数据库生成。article_id主键自动递增,createdupdated列的默认值设置为CURRENT_TIMESTAMP

  1. 下一步是初始化准备好的语句,并将变量值绑定到占位符——这个过程称为绑定 参数。添加以下代码:
// prepare the statement
$stmt = $conn->prepare($sql);
// bind the parameters and execute the statement
$stmt->bindParam(':title', $_POST['title'], PDO::PARAM_STR);
$stmt->bindParam(':article', $_POST['article'], PDO::PARAM_STR);
// execute and get number of affected rows
$stmt->execute();
$OK = $stmt->rowCount();

首先将 SQL 查询传递给数据库连接的prepare()方法($conn),并将对语句的引用存储为变量($stmt)。

接下来,变量中的值被绑定到准备好的语句中的占位符,然后execute()方法运行查询。

当与INSERTUPDATEDELETE查询一起使用时,PDO rowCount()方法报告受查询影响的行数。如果记录插入成功,$OK1,PHP 将其视为true。否则就是0,按false处理。

  1. 最后,将页面重定向到现有记录的列表,或者显示任何错误消息。在上一步之后添加以下代码:
// redirect if successful or display error
    if ($OK) {
        header('Location: http://localhost/php8sols/admin/blog_list_pdo.php');
        exit;
    } else {
        $error = $stmt->errorInfo()[2];
    }
}
?>

错误消息(如果有的话)被存储为由$stmt->errorInfo()返回的数组的第三个元素,并使用数组解引用来访问。

  1. 在页面正文中添加一个 PHP 代码块,以显示任何错误消息:
<h1>Insert New Blog Entry</h1>
<?php if (isset($error)) {
    echo "<p>Error: $error</p>";
} ?>
<form method="post" action="blog_insert_pdo.php">

完整的代码在ch15文件夹的blog_insert_pdo_02.php中。

这就完成了插入页面,但是在测试之前,创建blog_list_pdo.php,这将在下面描述。

链接到更新和删除页面

在更新或删除记录之前,您需要找到它的主键。一种实用的方法是查询数据库以选择所有记录。您可以使用此查询的结果来显示所有记录的列表,包括指向更新和删除页面的链接。通过将article_id的值添加到每个链接的查询字符串中,可以自动识别要更新或删除的记录。如图 15-2 所示,浏览器状态栏(左下方)显示的网址将文章Tiny Restaurants Crowded Togetherarticle_id标识为 3。

img/332054_5_En_15_Fig2_HTML.jpg

图 15-2

编辑和删除链接在查询字符串中包含记录的主键

更新页面使用它来显示准备更新的正确记录。相同的信息在指向删除页面的DELETE链接中传达。

要创建这样的列表,您需要从一个 HTML 表开始,该表包含两行和您想要显示的所有列,外加两个用于编辑和删除链接的额外列。第一行用于列标题。第二行包含在一个 PHP 循环中,显示所有结果。ch15文件夹中的blog_list_mysqli_01.php中的表格如下所示(blog_list_pdo_01.php中的版本是相同的,除了最后两个表格单元格中的链接指向 PDO 版本的更新和删除页面):

<table>
    <tr>
       <th>Created</th>
       <th>Title</th>
       <th>&nbsp;</th>
       <th>&nbsp;</th>
    </tr>
    <tr>
       <td></td>
       <td></td>
       <td><a href="blog_update_mysqli.php">EDIT</a></td>
       <td><a href="blog_delete_mysqli.php">DELETE</a></td>
    </tr>
</table>

PHP 解决方案 15-3:创建更新和删除页面的链接

这个 PHP 解决方案展示了如何创建一个页面,通过显示所有记录的列表并链接到更新和删除页面来管理blog表中的记录。MySQLi 和 PDO 版本之间只有微小的差异,所以这些说明对两者都进行了描述。

blog_list_mysqli_01.phpblog_list_pdo_01.php复制到admin文件夹,并将其保存为blog_list_mysqli.phpblog_list_pdo.php,这取决于您计划使用的连接方法。不同的版本链接到适当的插入、更新和删除文件。

  1. 您需要连接到数据库并创建 SQL 查询。在 PHP 块中的DOCTYPE声明上方添加以下代码:
require_once '../includes/connection.php';
require_once '../includes/utility_funcs.php';
// create database connection
$conn = dbConnect('read');
$sql = 'SELECT * FROM blog ORDER BY created DESC';

如果使用 PDO,将'pdo'作为第二个参数添加到dbConnect()中。

  1. 通过在结束 PHP 标记前添加以下代码来提交查询。

对于 MySQLi,使用这个:

$result = $conn->query($sql);
if (!$result) {
    $error = $conn->error;
}

对于 PDO,使用这个:

  1. 在表格前添加一个条件语句以显示任何错误消息,并将表格放在else块中。表格前的代码如下所示:

    <?php if (isset($error)) {
        echo "<p>$error</p>";
    } else { ?>
    
    
$result = $conn->query($sql);
$error = $conn->errorInfo()[2];

右花括号放在右</table>标签后的一个单独的 PHP 块中。

  • 对于 MySQLi,使用这个:
  1. 现在,您需要将第二个表行包含在一个循环中,并从结果集中检索每条记录。以下代码位于第一行的结束标签</tr>和第二行的开始标签<tr>之间。
</tr>
    <?php while($row = $result->fetch_assoc()) { ?>
<tr>

对于 PDO,使用这个:

</tr>
    <?php while ($row = $result->fetch()) { ?>
<tr>

这和上一章一样,所以应该不需要解释。

  1. 在第二行的前两个单元格中显示当前记录的createdtitle字段,如下所示:

    <td><?= $row['created'] ?></td>
    <td><?= safe($row['title']) ?></td>
    
    

created列存储一个TIMESTAMP数据类型,这是一个固定的格式,所以不需要净化。但是title列是文本相关的,所以需要传递给第十三章中定义的safe()函数。

  1. 在接下来的两个单元格中,将当前记录的查询字符串和article_id字段的值添加到两个 URL 中,如下所示(虽然链接不同,但突出显示的代码对于 PDO 版本是相同的):

    <td><a href="blog_update_mysqli.php?article_id=<?= $row['article_id'] ?>"
        >EDIT</a></td>
    <td><a href="blog_delete_mysqli.php?article_id=<?= $row['article_id'] ?>"
        >DELETE</a></td>
    
    

您在这里所做的是将?article_id=添加到 URL,然后使用 PHP 显示$row['article_id']的值。article_id列只存储整数,所以不需要对值进行清理。不要留下任何可能破坏 URL 或查询字符串的空格,这一点很重要。在处理完 PHP 之后,当在浏览器中查看页面的源代码时,开始的<a>标记应该是这样的(尽管数量会根据记录而变化):

  1. 最后,用花括号封闭第二个表格行周围的循环,如下所示:

    </tr>
        <?php } ?>
    </table>
    
    
  2. 保存blog_list_mysqli.phpblog_list_pdo.php并将页面加载到浏览器中。假设您之前已经将blog.sql的内容加载到了phpsols数据库中,您应该会看到一个包含四个条目的列表,如图 15-2 所示。你现在可以测试blog_insert_mysqli.php或者blog_insert_pdo.php。插入项目后,您将返回到blog_list.php的相应版本,创建日期和时间以及新项目的标题将显示在列表的顶部。如果遇到任何问题,对照ch15文件夹中的blog_list_mysqli_02.phpblog_list_pdo_02.php检查您的代码。

<a href="blog_update_mysqli.php?article_id=2">

提示这段代码假设表中总会有一些记录。作为练习,使用 PHP 解决方案 13-2 (MySQLi)或 13-4 (PDO)中的技术来计算结果的数量,如果没有找到记录,使用条件语句来显示消息。解决方案在blog_list_norec_mysqli.phpblog_list_norec_pdo.php里。

更新记录

更新页面需要执行两个独立的过程,如下所示:

  1. 检索所选记录,并显示它以备编辑

  2. 更新数据库中已编辑的记录

第一阶段使用$_GET超全局数组从 URL 中检索主键,然后使用它来选择并在更新表单中显示记录,如图 15-3 所示。

img/332054_5_En_15_Fig3_HTML.jpg

图 15-3

主键在更新过程中跟踪记录

主键存储在更新表单的隐藏字段中。在更新页面中编辑完记录后,使用post方法提交表单,将所有细节(包括主键)传递给UPDATE命令。

SQL UPDATE命令的基本语法如下所示:

UPDATE table_name SET column_name = value, column_name = value
WHERE condition

更新特定记录时的条件是主键。因此,当更新blog表中的article_id 3时,基本的UPDATE查询如下所示:

UPDATE blog SET title = value, article = value
WHERE article_id = 3

尽管 MySQLi 和 PDO 的基本原理是相同的,但代码差别很大,需要单独的指令。

PHP 解决方案 15-4:用 MySQLi 更新记录

这个 PHP 解决方案展示了如何将一个现有记录加载到更新表单中,然后使用 MySQLi 将编辑过的细节发送到数据库进行更新。要加载记录,您需要创建列出所有记录的管理页面,如 PHP 解决方案 15-3 中所述。

  1. ch15文件夹中复制blog_update_mysqli_01.php并在admin文件夹中保存为blog_update_mysqli.php

  2. 第一个阶段包括检索您想要更新的记录的详细信息。将以下代码放在 PHP 块中的DOCTYPE声明上方:

require_once '../includes/connection.php';
require_once '../includes/utility_funcs.php';
// initialize flags
$OK = false;
$done = false;
// create database connection
$conn = dbConnect('write');
// initialize statement
$stmt = $conn->stmt_init();
// get details of selected record
if (isset($_GET['article_id']) && !$_POST) {
    // prepare SQL query
    $sql = 'SELECT article_id, title, article
               FROM blog WHERE article_id = ?';
    if ($stmt->prepare($sql)) {
        // bind the query parameter
        $stmt->bind_param('i', $_GET['article_id']);
        // execute the query, and fetch the result
        $OK = $stmt->execute();
        // bind the results to variables
        $stmt->bind_result($article_id, $title, $article);
        $stmt->fetch();
    }
}
// redirect if $_GET['article_id'] not defined
if (!isset($_GET['article_id'])) {
    $url = 'http://localhost/php8sols/admin/blog_list_mysqli.php';
    header("Location: $url");
    exit;
}
// get error message if query fails
if (isset($stmt) && !$OK && !$done) {
    $error = $stmt->error;
}

尽管这非常类似于用于插入页面的代码,但是前几行是在条件语句之外的*。更新过程的两个阶段都需要数据库连接和准备好的语句,因此这避免了以后重复相同代码的需要。初始化两个标志:$OK检查检索记录是否成功,以及$done检查更新是否成功。*

第一个条件语句确保$_GET['article_id']存在,并且$_POST数组为空。因此,只有在设置了查询字符串,但表单还没有提交时,才会执行大括号内的代码。

您以与准备INSERT命令相同的方式准备SELECT查询,使用问号作为变量的占位符。但是,请注意,该查询不是使用星号来检索所有列,而是按名称指定三列,如下所示:

$sql = 'SELECT article_id, title, article
           FROM blog WHERE article_id = ?';

这是因为 MySQLi 预准备语句允许您将SELECT查询的结果绑定到变量,为了能够做到这一点,您必须指定列名和您希望它们出现的顺序。

首先,您需要初始化准备好的语句,并用$stmt->bind_param()$_GET['article_id']绑定到查询。因为article_id的值必须是整数,所以将'i'作为第一个参数传递。

代码执行查询,然后在获取结果之前,按照与在SELECT查询中指定的列相同的顺序将结果绑定到变量。

如果还没有定义$_GET['article_id'],下一个条件语句将页面重定向到blog_list_mysqli.php。这可以防止任何人试图直接在浏览器中加载更新页面。重定向位置已被分配给一个变量,因为如果更新成功,稍后将向该变量添加一个查询字符串。

如果预准备语句已创建,但$OK$done仍为false,则最终条件语句会存储一条错误消息。您还没有添加更新脚本,但是如果成功检索或更新记录,其中一个将切换到true。因此,如果两者都保持false,您就知道其中一个 SQL 查询有问题。

  1. 现在您已经检索了记录的内容,您需要在更新表单中显示它们。如果准备好的语句成功,$article_id应该包含要更新的记录的主键,因为它是您用bind_result()方法绑定到结果集的变量之一。

但是,如果有错误,您需要在屏幕上显示消息。但是如果有人将查询字符串更改为无效数字,$article_id将被设置为0,因此显示更新表单没有任何意义。在开始的<form>标签前添加以下条件语句:

<p><a href="blog_list_mysqli.php">List all entries </a></p>
<?php if (isset($error)) {
    echo "<p class='warning'>Error: $error</p>";
}
if($article_id == 0) { ?>
    <p class="warning">Invalid request: record does not exist.</p>
<?php } else { ?>
<form method="post" action="blog_update_mysqli.php">

第一条条件语句显示 MySQLi 预准备语句报告的任何错误消息。第二个将更新表单包装在一个else块中,所以如果$article_id0,表单将被隐藏。

  1. 在结束的</form>标签后立即添加else块的结束花括号,如下所示:

    </form>
           <?php } ?>
    </body>
    
    
  2. 如果$article_id不是0,你知道$title$article也包含有效值,可以显示在更新表单中,无需进一步测试。然而,您需要将文本值传递给safe(),以避免引号和可执行代码的问题。在title输入字段的value属性中显示$title,如下所示:

  3. article文本区做同样的操作。因为文本区域没有 value 属性,所以代码位于开始和结束的<textarea>标记之间,如下所示:

<input name="title" type="text" id="title" value="<?= safe($title) ?>">

<textarea name="article" id="article"><?= safe($article) ?></textarea>

确保开始和结束 PHP 和<textarea>标记之间没有空格。否则,您将在更新的记录中得到不需要的空格。

  1. UPDATE命令需要知道您想要更改的记录的主键。您需要将主键存储在一个隐藏字段中,以便与其他细节一起在$_POST数组中提交。因为隐藏字段不会显示在屏幕上,所以下面的代码可以放在表单中的任何位置:

  2. 保存更新页面,并通过将blog_list_mysqli.php加载到浏览器中并选择其中一条记录的EDIT链接来测试它。记录的内容应该显示在表单字段中,如图 15-3 所示。

<input name="article_id" type="hidden" value="<?= $article_id ?>">

Update Entry按钮还不能做任何事情。只要确保一切都正确显示,并确认主键在隐藏字段中注册。如果有必要,您可以对照blog_update_mysqli_02.php检查您的代码。

  1. 提交按钮的name属性是update,所以所有的更新处理代码都需要放在一个条件语句中,该语句检查$_POST数组中是否存在update。将下面以粗体突出显示的代码放在步骤 1 中重定向页面的代码的正上方:
$stmt->fetch();
    }
}
// if form has been submitted, update record
if (isset($_POST ['update'])) {
    // prepare update query
    $sql = 'UPDATE blog SET title = ?, article = ?
               WHERE article_id = ?';
    if ($stmt->prepare($sql)) {
        $stmt->bind_param('ssi', $_POST['title'], $_POST['article'],
            $_POST['article_id']);
        $done = $stmt->execute();
    }
}
// redirect page on success or if $_GET['article_id']) not defined
if ($done || !isset($_GET['article_id'])) {
    $url = 'http://localhost/php8sols/admin/blog_list_mysqli.php';
    if ($done) {
        $url .= '?updated=true';
    }
    header("Location: $url");
    exit;
}

UPDATE查询准备了问号占位符,其中的值由变量提供。准备好的语句已经在条件语句之外的代码中初始化,所以您可以将 SQL 传递给prepare()方法,并用$stmt->bind_param()绑定变量。前两个变量是字符串,第三个是整数,所以第一个参数是'ssi'

如果UPDATE查询成功,execute()方法返回true,重置$done的值。与INSERT查询不同,使用affected_rows属性没有什么意义,因为如果用户决定单击Update Entry按钮而不做任何更改,它将返回0,所以我们在这里不使用它。您需要将$done ||添加到重定向脚本的条件中。这确保了在更新成功或有人试图直接访问页面时页面被重定向。

如果更新成功,一个查询字符串将被追加到重定向位置。

  1. 编辑blog_list_mysqli.php中表格上方的 PHP 块,显示一条记录已被更新的消息,如下所示:
<?php if (isset($error)) {
    echo "<p>$error</p>";
} else {
    if (isset($_GET['updated'])) {
        echo '<p>Record updated</p>';
    }
?>
<table>

该条件语句嵌套在现有的else块中;不是elseif的说法。因此,在记录更新后,它将与数据库记录表一起显示。

  1. 保存blog_update_mysqli.php并通过加载blog_list_mysqli.php,选择一个EDIT链接,并对显示的记录进行更改来测试它。当您点击Update Entry时,您将被带回blog_list_mysqli.php,列表上方将出现“记录已更新”。您可以通过再次单击相同的EDIT链接来验证您所做的更改。如有必要,用blog_update_mysqli_03.phpblog_list_mysqli_03.php检查您的代码。
PHP 解决方案 15-5:用 PDO 更新记录

这个 PHP 解决方案展示了如何将现有记录加载到更新表单中,然后使用 PDO 将编辑后的详细信息发送到数据库进行更新。要加载记录,您需要创建列出所有记录的管理页面,如 PHP 解决方案 15-3 中所述。

  1. ch15文件夹中复制blog_update_pdo_01.php并在admin文件夹中保存为blog_update_pdo.php

  2. 第一个阶段包括检索您想要更新的记录的详细信息。将以下代码放在 PHP 块中的DOCTYPE声明上方:

require_once '../includes/connection.php';
require_once '../includes/utility_funcs.php';
// initialize flags
$OK = false;
$done = false;
// create database connection
$conn = dbConnect('write', 'pdo');
// get details of selected record
if (isset($_GET['article_id']) && !$_POST) {
    // prepare SQL query
    $sql = 'SELECT article_id, title, article FROM blog
                WHERE article_id = ?';
    $stmt = $conn->prepare($sql);
    // pass the placeholder value to execute() as a single-element array
    $OK = $stmt->execute([$_GET['article_id']]);
    // bind the results
    $stmt->bindColumn(1, $article_id);
    $stmt->bindColumn(2, $title);
    $stmt->bindColumn(3, $article);
    $stmt->fetch();
}
// redirect if $_GET['article_id'] not defined
if (!isset($_GET['article_id'])) {
    $url = 'http://localhost/php8sols/admin/blog_list_pdo.php';
    header("Location: $url");
    exit;
}
if (isset($stmt)) {
    // get error message (will be null if no error)
    $error = $stmt->errorInfo()[2];
}

虽然这非常类似于用于插入页面的代码,但是前几行是第一个条件语句之外的*。更新过程的两个阶段都需要数据库连接,因此这避免了以后复制相同代码的需要。初始化两个标志:$OK检查检索记录是否成功,以及$done检查更新是否成功。*

第一个条件语句检查$_GET['article_id']是否存在,以及$_POST数组是否为空。这确保了只有在设置了查询字符串,但表单还没有提交时,才执行里面的代码。

在为插入表单准备 SQL 查询时,您为变量使用了命名占位符。这次,我们用一个问号,像这样:

$sql = 'SELECT article_id, title, article FROM blog
           WHERE article_id = ?';

只有一个变量需要绑定到匿名占位符,所以将其作为单元素数组直接传递给execute()方法,如下所示:

$OK = $stmt->execute([$_GET['article_id']]);

Caution

这段代码使用数组简写语法,所以$_GET['article_id']被放在一对方括号中。不要忘记数组的右方括号。

然后用bindColumn()方法将结果绑定到$article_id$title$article。这一次,我使用数字(从 1 开始计数)来表示将每个变量绑定到哪一列。

结果中只有一条记录要获取,所以立即调用fetch()方法。

如果还没有定义$_GET['article_id'],下一个条件语句将页面重定向到blog_list_pdo.php。这可以防止任何人试图直接在浏览器中加载更新页面。重定向位置已被分配给一个变量,因为如果更新成功,稍后将向该变量添加一个查询字符串。

最后一条条件语句从准备好的语句中检索任何错误消息。它与其余的预准备语句代码是分开的,因为它还将用于您稍后将添加的第二个预准备语句。

  1. 现在您已经检索了记录的内容,您需要在更新表单中显示它们。如果准备好的语句成功,$article_id应该包含要更新的记录的主键,因为它是您用bindColumn()方法绑定到结果集的变量之一。

但是,如果有错误,您需要在屏幕上显示该消息。但是如果有人将查询字符串更改为无效数字,$article_id将被设置为0,因此显示更新表单没有任何意义。在开始的<form>标签前添加以下条件语句:

<p><a href="blog_list_pdo.php">List all entries </a></p>
<?php if (isset($error)) {
    echo "<p class='warning'>Error: $error</p>";
}
if($article_id == 0) { ?>
    <p class="warning">Invalid request: record does not exist.</p>
<?php } else { ?>
<form method="post" action="blog_update_pdo.php">

第一条条件语句显示 PDO 预处理语句报告的任何错误消息。第二个将更新表单包装在一个else块中,所以如果$article_id0,表单将被隐藏。

  1. 在结束的</form>标签后立即添加else块的结束花括号,如下所示:

    </form>
          <?php } ?>
    </body>
    
    
  2. 如果$article_id不是0,你知道$title$article也存在,可以显示在更新表单中,无需进一步测试。然而,您需要将文本值传递给safe(),以避免引号和可执行代码的问题。在title输入字段的value属性中显示$title,如下所示:

  3. article文本区做同样的操作。因为文本区域没有 value 属性,所以代码位于开始和结束的<textarea>标记之间,如下所示:

<input name="title" type="text" id="title" value="<?= safe($title) ?>">

<textarea name="article" id="article"><?= safe($article) ?></textarea>

确保开始和结束 PHP 和<textarea>标记之间没有空格。否则,您将在更新的记录中得到不需要的空格。

  1. UPDATE命令需要知道您想要更改的记录的主键。您需要将主键存储在一个隐藏字段中,以便与其他细节一起在$_POST数组中提交。因为隐藏字段不会显示在屏幕上,所以下面的代码可以放在表单中的任何位置:

  2. 保存更新页面,并通过将blog_list_pdo.php加载到浏览器中并选择其中一条记录的EDIT链接来测试它。记录的内容应该显示在表单字段中,如图 15-3 所示。

<input name="article_id" type="hidden" value="<?= $article_id ?>">

Update Entry按钮还不能做任何事情。只要确保一切都正确显示,并确认主键在隐藏字段中注册。如果有必要,您可以对照blog_update_pdo_02.php检查您的代码。

  1. 提交按钮的name属性是update,所以所有的更新处理代码都需要放在一个条件语句中,该语句检查$_POST数组中是否存在update。将下面以粗体突出显示的代码放在步骤 1 中重定向页面的代码的正上方:
$stmt->fetch();
}
// if form has been submitted, update record
if (isset($_POST['update'])) {
    // prepare update query
    $sql = 'UPDATE blog SET title = ?, article = ?
               WHERE article_id = ?';
    $stmt = $conn->prepare($sql);
    // execute query by passing array of variables
    $done = $stmt->execute([$_POST['title'], $_POST['article'],
        $_POST['article_id']]);
}
// redirect page on success or $_GET['article_id'] not defined
if ($done || !isset($_GET['article_id'])) {
    $url = 'http://localhost/php8sols/admin/blog_list_pdo.php';
    if ($done) {
        $url .= '?updated=true';
    }
    header("Location: $url");
    exit;
}

同样,SQL 查询是使用问号作为从变量派生的值的占位符来准备的。这一次,有三个占位符,因此相应的变量需要作为数组传递给execute()方法。不用说,数组的顺序必须与占位符的顺序相同。

如果UPDATE查询成功,execute()方法返回true,重置$done的值。这里不能使用rowCount()方法来获得受影响的行数,因为如果没有做任何更改就点击Update Entry按钮,它会返回0。您会注意到我们在重定向脚本的条件中添加了$done ||。这确保了在更新成功或有人试图直接访问页面时页面被重定向。如果记录已被更新,一个查询字符串将被追加到重定向位置。

  1. 编辑blog_list_pdo.php中表格上方的 PHP 块,显示一条记录已被更新的消息,如下所示:
<?php if (isset($error)) {
    echo "<p>$error</p>";
} else {
    if (isset($_GET['updated'])) {
        echo '<p>Record updated</p>';
    }
?>
<table>

该条件语句嵌套在现有的else块中;不是elseif的说法。因此,在记录更新后,它将与数据库记录表一起显示。

  1. 保存blog_update_pdo.php并通过加载blog_list_pdo.php,选择一个EDIT链接,并对显示的记录进行更改来测试它。当您点击Update Entry时,您将被带回blog_list_pdo.php,列表上方将出现“记录已更新”。您可以通过再次单击相同的EDIT链接来验证您所做的更改。如有必要,对照blog_update_pdo_03.phpblog_list_pdo_03.php检查您的代码。

删除记录

删除数据库中的记录类似于更新记录。基本的DELETE命令如下所示:

DELETE FROM table_name WHERE condition

DELETE命令具有潜在危险的是,它是最终命令。一旦你删除了一条记录,就再也无法恢复了——它永远消失了。没有回收站或垃圾桶来把它捞出来。更糟糕的是,WHERE子句是可选的。如果你忽略了它,表中的每一条记录都会不可挽回地被送进网络遗忘。因此,最好显示要删除的记录的详细信息,并要求用户确认或取消该过程(参见图 15-4 )。

img/332054_5_En_15_Fig4_HTML.jpg

图 15-4

删除记录是不可逆的,所以在继续之前要得到确认

构建和编写删除页面的脚本几乎与更新页面相同,所以我不会给出一步一步的说明。但是,以下是要点:

  • 检索所选记录的详细信息。

  • 显示足够的详细信息,如标题,以便用户确认选择了正确的记录。

  • Confirm DeletionCancel按钮赋予不同的name属性,使用每个name属性和isset()来控制所采取的动作。

  • 使用条件语句隐藏Confirm Deletion按钮和隐藏字段,而不是将整个表单包装在else块中。

为每个方法执行删除的代码如下。

对于 MySQLi:

if (isset($_POST['delete'])) {
    $sql = 'DELETE FROM blog WHERE article_id = ?';
    if ($stmt->prepare($sql)) {
        $stmt->bind_param('i', $_POST['article_id']);
        $stmt->execute();
        if ($stmt->affected_rows > 0) {;
            $deleted = true;
        } else {
            $error = 'There was a problem deleting the record.';
        }
    }
}

对于 PDO:

if (isset($_POST['delete'])) {
    $sql = 'DELETE FROM blog WHERE article_id = ?';
    $stmt = $conn->prepare($sql);
    $stmt->execute([$_POST['article_id']]);
    // get number of affected rows
    $deleted = $stmt->rowCount();
    if (!$deleted) {
        $error = 'There was a problem deleting the record.';
        $error .= $stmt->errorInfo()[2];
    }
}

你可以在ch15文件夹的blog_delete_mysqli.phpblog_delete_pdo.php中找到完成的代码。为了测试删除脚本,将适当的文件复制到admin文件夹中。

回顾四个基本的 SQL 命令

既然你已经看到了SELECTINSERTUPDATEDELETE的运行,让我们回顾一下 MySQL 和 MariaDB 的基本语法。这不是一个详尽的列表,但它集中在最重要的选项上,包括一些尚未涉及的选项。

我在 https://dev.mysql.com/doc/refman/8.0/en/ 使用了与 MySQL 在线手册相同的排版约定(您可能也想参考):

  • 任何大写的都是 SQL 命令。

  • 方括号中的表达式是可选的。

  • 小写斜体表示变量输入。

  • 一个竖线(|)分隔选项。

尽管有些表达式是可选的,但它们必须按列出的顺序出现。例如,在一个SELECT查询中,WHEREORDER BYLIMIT都是可选的,但是LIMIT不能出现在WHEREORDER BY之前。

挑选

SELECT用于从一个或多个表中检索记录。其基本语法如下:

SELECT [DISTINCT] select_list
FROM table_list
[WHERE where_expression]
[ORDER BY col_name | formula] [ASC | DESC]
[LIMIT [skip_count,] show_count]

DISTINCT选项告诉数据库您想要从结果中消除重复的行。

select_list 是您希望包含在结果中的列的逗号分隔列表。若要检索所有列,请使用星号(*)。如果同一个列名在多个表中使用,引用必须明确,使用语法 table_name.column_name 。第 17 和 18 章详细解释了如何使用多个表格。

table_list 是一个逗号分隔的列表,从中可以提取结果。您希望包含在结果中的所有表格都必须列出。

WHERE子句指定搜索标准,例如:

WHERE quotations.family_name = authors.family_name
WHERE article_id = 2

表达式可以使用比较、算术、逻辑和模式匹配运算符。最重要的在表 15-2 中列出。

表 15-2

MySQL WHERE 表达式中使用的主要运算符

|

比较

|   |

算术

|   | | --- | --- | --- | --- | | < | 不到 | + | 添加 | | <= | 小于或等于 | - | 减法 | | = | 等于 | * | 增加 | | != | 不等于 | / | 分开 | | <> | 不等于 | DIV | 整数除法 | | > | 大于 | % | 系数 | | >= | 大于或等于 |   |   | | IN() | 包括在列表中 |   |   | | BETWEEN 最小值 AND 最大值 | 介于(包括两个值) |   |   | | 逻辑 |   | 模式匹配 |   | | AND | 逻辑与 | LIKE | 不区分大小写匹配 | | && | 逻辑与 | NOT LIKE | 不区分大小写的不匹配 | | OR | 逻辑或 | LIKE BINARY | 区分大小写匹配 | | &#124;&#124; | 逻辑或(最好避免) | NOT LIKE BINARY | 区分大小写不匹配 |

在表示“不等于”的两个运算符中,<>是标准的 SQL。不是所有的数据库都支持!=

DIV是模数运算符的对应物。它产生的除法结果是一个没有小数部分的整数,而模数只产生余数:

5 / 2        /* result 2.5 */
5 DIV 2      /* result 2  */
5 % 2        /* result 1  */

我建议您避免使用||,因为它实际上在标准 SQL 中被用作字符串连接操作符。通过不在 MySQL 中使用它,如果使用不同的关系数据库,可以避免混淆。为了连接字符串,MySQL 使用了CONCAT()函数(参见 https://dev.mysql.com/doc/refman/8.0/en/string-functions.html#function_concat )。

IN()计算括号内逗号分隔的值列表,如果找到一个或多个值,则返回true。虽然BETWEEN通常用于数字,但它也适用于字符串。例如,BETWEEN 'a' AND 'd'为 *a、b、c、*和 d 返回true(但不是它们的大写等价物)。IN()BETWEEN都可以在NOT之前进行相反的比较。

LIKENOT LIKE和相关的BINARY运算符与以下两个通配符一起用于文本搜索:

  • %:匹配任意字符序列或不匹配

  • _(下划线):仅匹配一个字符

因此,下面的WHERE子句匹配 Dennis、Denise 等,但不匹配 Aiden:

WHERE first_name LIKE 'den%'

要匹配 Aiden,请将%放在搜索模式的前面。因为%匹配任何字符序列或者不匹配,所以'%den%'仍然匹配丹尼斯和丹尼斯。要搜索文字百分号或下划线,请在它前面加一个反斜杠(\%\_)。

条件是从左到右计算的,但是如果您希望将一组条件放在一起考虑,可以用括号将它们分组。

ORDER BY指定结果的排序顺序。这可以指定为单个列、逗号分隔的列列表或类似于RAND()的表达式,这将使顺序随机化。默认的排序顺序是升序(a-z,0-9),但是您可以指定DESC(降序)来颠倒顺序。

LIMIT后跟一个数字,规定返回的最大记录数。如果两个数字用逗号分隔,第一个告诉数据库要跳过多少行(参见第十四章中的“选择记录子集”)。

关于SELECT的更多细节见 https://dev.mysql.com/doc/refman/8.0/en/select.html

插入

INSERT命令用于向数据库添加新记录。一般语法如下:

INSERT [INTO] table_name (column_names)
VALUES (values)

单词INTO是可选的;它只是让命令读起来更像人类语言。列名和值是以逗号分隔的列表,并且两者的顺序必须相同。因此,要将纽约(暴风雪)、底特律(烟雾)和檀香山(晴天)的天气预报插入到天气数据库中,应该这样做:

INSERT INTO forecast (new_york, detroit, honolulu)
VALUES ('blizzard', 'smog', 'sunny')

使用这种语法的原因是允许您一次插入多条记录。每个后续记录都在一组单独的括号中,每组用逗号分隔:

INSERT numbers (x,y)
VALUES (10,20),(20,30),(30,40),(40,50)

你将在第十八章中使用这个多重插入语法。从INSERT查询中省略的任何列都被设置为默认值。当列设置为 AUTO_INCREMENT时,不要为主键设置显式值;在INSERT语句中去掉列名。

详见 https://dev.mysql.com/doc/refman/8.0/en/insert.html

更新

该命令用于更改现有记录。基本语法如下所示:

UPDATE table_name
SET col_name = value [, col_name = value]
[WHERE where_expression]

WHERE表达式告诉 MySQL 您想要更新哪条或哪些记录(或者可能在下面的例子中,梦见):

UPDATE sales SET q4_2021 = 25000
WHERE title = 'PHP 8 Solutions, Fifth Edition'

关于UPDATE的更多细节,请参见 https://dev.mysql.com/doc/refman/8.0/en/update.htm l

删除

DELETE可用于删除单个记录、多个记录或表格的全部内容。从单个表中删除的一般语法如下:

DELETE FROM table_name [WHERE where_expression]

尽管 phpMyAdmin 在删除记录之前会提示您进行确认,但数据库会相信您的话,并立即执行删除。DELETE是完全不可原谅的——一旦数据被删除,它就永远消失了*。以下查询将删除名为subscribers的表中的所有记录,其中expiry_date中的日期已经过去:*

DELETE FROM subscribers
WHERE expiry_date < NOW()

详见 https://dev.mysql.com/doc/refman/8.0/en/delete.html

Caution

虽然在UPDATEDELETEWHERE子句是可选的,但是您应该知道,如果您省略WHERE,整个表都会受到影响。这意味着,这两个命令中的任何一个不小心出错都可能导致每一条记录都完全相同,或者被删除。

安全和错误消息

当用 PHP 和数据库开发一个网站时,显示错误信息是很重要的,这样你就可以在出错时调试你的代码。然而,原始的错误消息在真实的网站上看起来很不专业。它们还可以向潜在的攻击者透露有关您的数据库结构的线索。因此,在将您的脚本部署到 Internet 上之前,您应该用您自己的中性消息替换数据库生成的错误消息,例如“对不起,数据库不可用”

章节回顾

数据库的内容管理包括插入、选择、更新和删除记录。每个记录的主键在更新和删除过程中起着重要的作用。大多数情况下,当第一次创建记录时,生成主键是由数据库自动处理的。此后,查找记录的主键只需使用一个SELECT查询,要么显示所有记录的列表,要么搜索您知道的关于记录的信息,比如一篇文章中的标题或单词。

MySQLi 和 PDO 预处理语句消除了确保引号和控制字符正确转义的需要,从而使数据库查询更加安全。如果在一个脚本中需要使用不同的变量重复相同的查询,它们还可以提高应用的速度。脚本只需要用占位符验证一次,而不是每次都验证 SQL。

尽管本章集中讨论了内容管理,但同样的基本技术也适用于大多数与数据库的交互。当然,对于 SQL 和 PHP 来说,还有很多东西。在下一章,我将解决一些最常见的问题,比如只显示一个长文本字段的第一句话和处理日期。然后在第十七章中,我们将探索在一个数据库中使用多个表。*

十六、格式化文本和日期

我们有一些前一章遗留下来的未完成的工作。第十五章中的图 15-1 显示了blog表格中的内容,其中只显示了每篇文章的前两句话以及文章其余部分的链接。然而,我没有向你展示它是如何做到的。有几种方法可以从较长文本的开头提取较短的文本。有些相当粗糙,通常在结尾留给你一个残破的单词。在这一章中,你将学习如何提取完整的句子。

另一项未完成的工作是,blog_list_mysqli.phpblog_list_pdo.php中的完整文章列表显示了原始状态的 MySQL 时间戳,这不是很优雅。你需要重新设置日期的格式,让它看起来更方便用户。处理日期可能是一个令人头疼的问题,因为 MySQL 和 MariaDB 存储日期的方式与 PHP 完全不同。本章将指导你如何在 PHP/MySQL 环境中存储和显示日期。您还将了解 PHP 的日期和时间特性,这些特性可以进行复杂的日期计算,例如查找每个月的第二个星期二,这是小菜一碟。

在本章中,您将了解以下内容:

  • 提取较长文本项的第一部分

  • 在 SQL 查询中使用别名

  • 将从数据库中检索到的文本显示为段落

  • 用 MySQL 格式化日期

  • 基于时间标准选择记录

  • 使用 PHP 的DateTimeDateTimeZoneDateIntervalDatePeriod

显示文本摘要

有许多方法可以从一段较长的文本中提取前几行或前几个字符。有时你只需要前 20 或 30 个字符来识别一个项目。在其他时候,最好显示完整的句子或段落。

提取固定数量的字符

您可以使用 PHP substr()函数或 SQL 查询中的LEFT()函数从文本项的开头提取固定数量的字符。

Note

以下示例将文本传递给第十三章中定义的safe()函数。这通过将&符号、双引号和尖括号转换为它们的 HTML 字符实体等效项来净化来自外部源的文本,但防止现有实体被双重编码。函数定义包含在文件utility_funcs.php中。

使用 PHP substr()函数

substr()函数从一个较长的字符串中提取一个子字符串。它有三个参数:要从中提取子字符串的字符串、起始点(从 0 开始计数)和要提取的字符数。以下代码显示了$row['article']的前 100 个字符:

echo safe(substr($row['article'], 0, 100));

原始字符串保持不变。如果省略第三个参数,substr()将提取字符串末尾的所有内容。只有当您选择 0 以外的起点时,这才有意义。

在 SQL 查询中使用 LEFT()函数

LEFT()函数从一列的开头提取字符。它有两个参数:列名和要提取的字符数。下面的代码从blog表的article列中检索article_idtitle和前 100 个字符:

SELECT article_id, title, LEFT(article, 100)
FROM blog ORDER BY created DESC

每当您像这样在 SQL 查询中使用函数时,列名在结果集中不再显示为article,而是显示为LEFT(article, 100)。所以使用AS关键字为受影响的列分配一个别名是个好主意。您可以将列的原始名称重新指定为别名,或者使用描述性名称,如下例所示(代码在blog_left_mysqli.php文件夹中的blog_left_pdo.php文件夹中):

SELECT article_id, title, LEFT(article, 100) AS first100
FROM blog ORDER BY created DESC

如果您将每个记录作为$row处理,那么摘录在$row['first100']中。要检索前 100 个字符和整篇文章,只需在查询中包含这两个字符,如下所示:

SELECT article_id, title, LEFT(article, 100) AS first100, article
FROM blog ORDER BY created DESC

取固定数量的字符会产生一个粗略的结果,如图 16-1 所示。

img/332054_5_En_16_Fig1_HTML.jpg

图 16-1

从一篇文章中选择前 100 个字符会把许多单词砍掉一半

结束对完整单词的提取

要结束对一个完整单词的提取,需要找到最后一个空格,并使用它来确定子串的长度。因此,如果您希望摘录最多为 100 个字符,可以使用前面的方法之一开始,并将结果存储在$extract中。然后你可以使用 PHP 字符串函数strrpos()substr()找到最后一个空格,并像这样结束提取(代码在blog_word_mysqli.phpblog_word_pdo.php):

$extract = $row['first100'];
// find position of last space in extract
$lastSpace = strrpos($extract, ' ');
// use $lastSpace to set length of new extract and add ...
echo safe(substr($extract, 0, $lastSpace)) . '... ';

这产生了如图 16-2 所示的更优雅的结果。它使用strrpos(),它在另一个字符串中找到一个字符或子字符串的最后一个位置。因为您在寻找一个空格,所以第二个参数是一对中间有一个空格的引号。结果存储在$lastSpace中,作为第三个参数传递给substr(),完成对一个完整单词的提取。最后,添加一个包含三个点和一个空格的字符串,并用连接操作符(一个句点或点)将两者连接起来。

img/332054_5_En_16_Fig2_HTML.jpg

图 16-2

在一个完整的单词上结束提取会产生一个更优雅的结果

Caution

不要将获取字符或子串最后一个位置的strrpos()与获取第一个位置的strpos()混淆。额外的“r”代表“反向”——strrpos()从字符串末尾开始搜索。

提取第一段

假设您已经使用 Enter 或 Return 键在数据库中输入了文本以指示新段落,这是非常容易的。只需检索全文,使用strpos()找到第一个换行符,使用substr()提取到该点的第一部分文本。

以下 SQL 查询用于blog_para_mysqli.phpblog_para_pdo.php:

SELECT article_id, title, article
FROM blog ORDER BY created DESC

以下代码用于显示article的第一段:

<?= safe(substr($row['article'], 0, strpos($row['article'], PHP_EOL))) ?>

让我们把它拆开,单独看一下第三个论点:

strpos($row['article'], PHP_EOL)

这使用PHP_EOL常量以跨平台的方式在$row['article']中定位行字符的第一个结尾(参见 7 一章中的“用 fopen()追加内容”)。您可以像这样重写代码:

$newLine = strpos($row['article'], PHP_EOL);
echo safe(substr($row['article'], 0, $newLine));

两组代码做的完全一样,但是 PHP 允许您将一个函数作为传递给另一个函数的参数进行嵌套。只要嵌套函数返回有效结果,您就可以经常使用这样的快捷方式。

使用PHP_EOL常量消除了处理 Linux、macOS 和 Windows 用来插入新行的不同字符的问题。

显示段落

既然我们谈到了段落这个主题,许多初学者会对从数据库中检索到的所有文本都显示为一个连续的块而感到困惑,因为段落之间没有分隔。HTML 忽略空白,包括新行。要将存储在数据库中的文本显示为段落,您有以下选择:

  • 将文本存储为 HTML。

  • 将新行转换为<br/>标签。

  • 创建一个自定义函数,用段落标签替换新行。

将数据库记录存储为 HTML

第一个选项包括在您的内容管理表单中安装一个 HTML 编辑器,例如 CKEditor ( https://ckeditor.com/ )或 TinyMCE ( www.tiny.cloud/ )。在插入或更新文本时对其进行标记。HTML 存储在数据库中,文本按预期显示。安装这些编辑器超出了本书的范围。

Note

如果你将文本作为 HTML 存储在数据库中,你不能使用safe()函数来显示它,因为 HTML 标签将作为文本的一部分显示。相反,使用strip_tags()并指定允许哪些标签(参见第七章和 www.php.net/manual/en/function.strip-tags.php 中的“访问远程文件”)。

将新行转换为

标签

最简单的选择是在显示之前将文本传递给nl2br()函数,如下所示:

echo nl2br(safe($row['article']));

瞧啊。段落。不完全是。nl2br()函数将换行符转换为<br/>标签(右斜杠是为了与 XHTML 兼容,在 HTML5 中有效)。结果,你得到的是假的段落。这是一个快速而肮脏的解决方案,但并不理想。

Tip

使用nl2br()是一个次优的解决方案。但是如果您决定使用它,您必须在将它传递给nl2br()之前净化文本。否则,<br />标签的尖括号将被转换成 HTML 字符实体,导致它们显示在你的文本中,而不是作为底层 HTML 中的标签。

创建一个函数来插入

标签

要将从数据库中检索到的文本显示为真正的段落,将数据库结果包装在一对段落标签中,然后使用preg_replace()函数将连续的换行符转换为结束</p>标签,紧接着是开始<p>标签,如下所示:

<p><?= preg_replace('/[\r\n]+/', "</p>\n<p>", safe($row['article'])); ?></p>

用作第一个参数的正则表达式匹配一个或多个回车和/或换行符。这里不能使用PHP_EOL常量,因为需要匹配所有连续的换行符,并用一对段落标签替换它们。这对<p>标记用双引号括起来,中间用\n加一个换行符,以便让 HTML 代码更容易阅读。记住正则表达式的模式可能很困难,因此您可以轻松地将其转换为自定义函数,如下所示:

function convertToParas($text) {
    $text = trim($text);
    $text = htmlspecialchars($text, double_encode: false);
    return '<p>' . preg_replace('/[\r\n]+/', "</p>\n<p>", $text) . "</p>\n";
}

这会从文本的开头和结尾修剪空白,包括换行符,然后通过将它传递给带有double_encode命名参数的htmlspecialchars()函数来净化空白,以防止 HTML 实体的&符号被转换为&amp;。函数内的第二行代码与第十三章中定义的safe()函数相同。最后一行在开头添加了一个<p>标签,用结束和开始标签替换了换行符的内部序列,并在结尾追加了一个结束</p>标签和换行符。

然后,您可以像这样使用该函数:

<?= convertToParas($row['article']); ?>

函数定义的代码在ch16文件夹中utility_funcs.php的更新版本中。你可以看到它被用在blog_ptags_mysqli.phpblog_ptags_pdo.php中。

Note

尽管utility_funcs.php的更新版本包含了safe()convertToParas()函数定义,但我决定不在convertToParas()中调用safe()函数,因为这可能会创建一个潜在的不稳定依赖。如果在将来的某个阶段,您决定采用不同的方式来净化文本并删除了safe()函数定义,调用convertToParas()将会触发致命错误,因为它依赖于一个不再存在的自定义函数。

提取完整的句子

PHP 对句子的构成没有概念。计算句号意味着你会忽略所有以感叹号或问号结尾的句子。您还会冒在小数点上断句或在句号后截断右引号的风险。为了克服这些问题,我设计了一个名为getFirst()的 PHP 函数,它可以识别普通句子末尾的标点符号:

  • 句号、问号或感叹号

  • 可选地后跟单引号或双引号

  • 后跟一个或多个空格

getFirst()函数有两个参数:要从中提取第一部分的文本和要提取的句子数量。第二个参数是可选的;如果没有提供,该函数将提取前两个句子。代码看起来是这样的(在utility_funcs.php中):

function getFirst($text, $number=2) {
    // use regex to split into sentences
    $sentences = preg_split('/([.?!]["\']?\s)/', $text, $number+1,
        PREG_SPLIT_DELIM_CAPTURE);
    if (count($sentences) > $number * 2) {
        $remainder = array_pop($sentences);
    } else {
        $remainder = '';
    }
    $result = [];
    $result[0] = implode('', $sentences);
    $result[1] = $remainder;
    return $result;
}

这个函数返回一个包含两个元素的数组:提取的句子和任何剩下的文本。您可以使用第二个元素创建一个包含全文的页面链接。

以粗体突出显示的行使用正则表达式来标识每个句子的结尾—句号、问号或感叹号,后面可选地跟一个双引号或单引号以及一个空格。这作为第一个参数传递给preg_split(),它使用正则表达式将文本分割成一个数组。第二个参数是目标文本。第三个参数决定了将文本分割成的最大块数。你想要比要提取的句子数量多一个。通常,preg_split()会丢弃正则表达式匹配的字符,但是使用PREG_SPLIT_DELIM_CAPTURE作为第四个参数,并在正则表达式中使用一对捕获括号,将它们作为单独的数组元素保存下来。换句话说,$sentences数组的元素交替地由一个句子的文本后跟标点符号和空格组成,如下所示:

$sentences[0] = '"Hello, world';
$sentences[1] = '!" ';

不可能事先知道目标文本中有多少个句子,所以你需要找出在提取出所需数量的句子后是否还有剩余。条件语句使用count()来确定$sentences数组中元素的数量,并将结果与$number乘以 2 进行比较(因为数组中每个句子包含两个元素)。如果还有更多文本,array_pop()删除$sentences数组的最后一个元素,并将其分配给$remainder。如果没有进一步的文本,$remainder是一个空字符串。

该函数的最后一步使用带有空字符串的implode()作为第一个参数,将提取的句子拼接在一起,然后返回一个包含提取的文本和任何剩余内容的两元素数组。

如果你发现这个解释很难理解,不要担心。代码相当高级。构建这个函数需要大量的实验,这些年来我一直在逐步改进它。

PHP 解决方案 16-1:显示文章的前两句话

这个 PHP 解决方案展示了如何使用上一节中描述的getFirst()函数显示blog表中每篇文章的摘录。如果你在书的前面创建了 Japan Journey 站点,使用blog.php。或者,使用ch16文件夹中的blog_01.php,并在php8sols站点根目录中将其保存为blog.php。在includes文件夹中还需要footer.phpmenu.phptitle.phpconnection.php。如果includes文件夹中没有这些文件,那么ch16文件夹中会有它们的副本。

  1. utility_funcs.php的更新版本从ch16文件夹复制到includes文件夹,并将其包含在DOCTYPE声明上方的 PHP 代码块的blog.php中。还包括connection.php并创建一个到数据库的连接。该页面需要只读权限,所以使用read作为传递给dbConnect()的参数,如下所示:

    require_once './includes/connection.php';
    require_once './includes/utility_funcs.php';
    // create database connection
    $conn = dbConnect('read');
    
    

如果使用 PDO,将'pdo'作为第二个参数添加到dbConnect()中。

  1. 准备一个 SQL 查询,从blog表中检索所有记录,然后提交它,如下所示:

  2. 添加代码以检查数据库错误。

$sql = 'SELECT * FROM blog ORDER BY created DESC';
$result = $conn->query($sql);

对于 MySQLi,使用这个:

if (!$result) {
    $error = $conn->error;
}

对于 PDO,调用errorInfo()方法并检查第三个数组元素是否存在,如下所示:

  1. 删除页面主体中<main>元素内的所有静态 HTML,并添加代码,以便在查询出现问题时显示错误消息:

    <main>
    <?php if (isset($error)) {
        echo "<p>$error</p>";
    } else {
    }
    ?>
    </main>
    
    
  2. else块内创建一个循环来显示结果:

    while ($row = $result->fetch_assoc()) {
        echo "<h2>{$row['title']}</h2>";
        $extract = getFirst($row['article']);
        echo '<p>' . safe($extract[0]);
        if ($extract[1]) {
            echo '<a href="details.php?article_id=' . $row['article_id'] . '">
                More</a>';
        }
        echo '</p>';
    }
    
    
$errorInfo = $conn->errorInfo();
if (isset($errorInfo[2])) {
    $error = $errorInfo[2];
}

PDO 的代码是一样的,除了这一行:

while ($row = $result->fetch_assoc()) {

替换为以下内容:

while ($row = $result->fetch()) {

getFirst()函数处理$row['article']并将结果存储在$extract中。$extract[0]article的前两句立刻显示出来。如果$extract[1]包含任何内容,则意味着有更多内容要显示。因此,if块中的代码显示了一个到details.php的链接,文章的主键在一个查询字符串中。

img/332054_5_En_16_Fig3_HTML.jpg

图 16-3

前两个句子是从较长的文本中干净利落地提取出来的

  1. 保存页面并在浏览器中测试。你应该会看到每篇文章的前两句显示如图 16-3 所示。

  2. 通过向getFirst()添加一个数字作为第二个参数来测试函数,如下所示:

$extract = getFirst($row['article'], 3);

这将显示前三个句子。如果您增加该数字,使其等于或超过文章中的句子数,则不会显示“更多”链接。

您可以将您的代码与ch16文件夹中的blog_mysqli.phpblog_pdo.php进行比较。

我们将在第十七章中看到details.php。在此之前,让我们先来解决在动态网站中使用日期的雷区。

让我们约会吧

日期和时间对现代生活如此重要,以至于我们很少停下来思考它们有多复杂。一分钟有 60 秒,一小时有 60 分钟,但一天有 24 小时。月份的范围在 28 到 31 天之间,一年可以是 365 或 366 天。困惑不止于此,因为 7/4 对美国人或日本人来说意味着 7 月 4 日,但对欧洲人来说是 4 月 7 日。更令人困惑的是,PHP 处理日期的方式与 MySQL 不同。是时候让混乱变得有序了…

Note

MariaDB 以同样的方式处理日期。为了避免不必要的重复,我将只提到 MySQL。

MySQL 如何处理日期

在 MySQL 中,日期和时间总是按照从大到小的降序来表示:年、月、日、小时、分钟、秒。小时总是使用 24 小时制,午夜表示为 00:00:00。即使这对您来说似乎很陌生,但这是国际标准化组织(ISO)制定的建议。

MySQL 允许单元之间的分隔符有相当大的灵活性(任何标点符号都是可以接受的),但是顺序是没有争议的——它是固定的。如果您试图以年、月、日之外的任何其他格式存储日期,MySQL 会在数据库中插入 0000-00-00。

稍后我将回到您在 MySQL 中插入日期的方式,因为最好使用 PHP 来验证和格式化它们。首先,让我们看看一旦日期存储在数据库中,您可以对其做些什么。MySQL 有很多日期和时间函数,在 https://dev.mysql.com/doc/refman/8.0/en/date-and-time-functions.html 有举例列出。

最有用的函数之一是DATE_FORMAT(),正如其名所示。

用 DATE_FORMAT()格式化选择查询中的日期

DATE_FORMAT()的语法如下:

DATE_FORMAT(date, format)

通常, date 是要格式化的表格列, format 是由格式说明符和您想要包含的任何其他文本组成的字符串。表 16-1 列出了最常见的说明符,它们都区分大小写。

表 16-1

常用的 MySQL 日期格式说明符

|

时期

|

分类符

|

描述

|

例子

| | --- | --- | --- | --- | | 年 | %Y | 四位数格式 | Two thousand and twenty-one | | %y | 两位数格式 | Twenty-one | | 月 | %M | 全名 | 一月,九月 | | %b | 缩写名,三个字母 | 杨,Sep | | %m | 带前导零的数字 | 01, 09 | | %c | 不带前导零的数字 | 1, 9 | | 一月中的某一天 | %d | 带前导零 | 01, 25 | | %e | 不带前导零 | 1, 25 | | %D | 带英文文本后缀 | 第 1 次、第 25 次 | | 工作日名称 | %W | 全面测试 | 星期一,星期四 | | %a | 缩写名,三个字母 | 我的,Thu | | 小时 | %H | 带前导零的 24 小时制时钟 | 01, 23 | | %k | 不带前导零的 24 小时制时钟 | 1, 23 | | %h | 带前导零的 12 小时制时钟 | 01, 11 | | %l(小写“L”) | 不带前导零的 12 小时制时钟 | 1, 11 | | 分钟 | %i | 带前导零 | 05, 25 | | 秒 | %S | 带前导零 | 08, 45 | | 上午/下午 | %p |   |   |

如前所述,在 SQL 查询中使用函数时,使用关键字AS将结果分配给一个别名。参照表 16-1 ,您可以将blog表的created列中的日期格式化为美国通用的格式,并为其指定一个别名,如下所示:

DATE_FORMAT(created, '%c/%e/%Y') AS date_created

要以欧洲风格格式化同一日期,请颠倒前两个说明符,如下所示:

DATE_FORMAT(created, '%e/%c/%Y') AS date_created

Tip

当使用DATE_FORMAT()时,不要使用原来的列名作为别名,因为值被转换成字符串,这会破坏排序顺序。选择不同的别名,并使用原始列名对结果进行排序。

PHP 解决方案 16-2:格式化 MySQL 日期或时间戳

这个 PHP 解决方案格式化了第十五章的博客条目管理页面中的日期。

  1. 打开admin文件夹中的blog_list_mysqli.phpblog_list_pdo.php,找到 SQL 查询。看起来是这样的:

  2. 像这样改变它:

$sql = 'SELECT * FROM blog ORDER BY created DESC';

       $sql = 'SELECT article_id, title,
DATE_FORMAT(created, "%a, %b %D, %Y") AS date_created
                   FROM blog ORDER BY created DESC';

我在整个 SQL 查询中使用了单引号,所以DATE_FORMAT()中的格式字符串需要用双引号括起来。

确保DATE_FORMAT()的左括号前没有空白。

格式字符串以%a开头,显示工作日名称的前三个字母。如果使用原来的列名作为别名,那么ORDER BY子句将按相反的字母顺序对日期进行排序:Wed、Thu、Sun 等等。使用不同的别名可以确保日期仍然按时间顺序排列。

img/332054_5_En_16_Fig4_HTML.jpg

图 16-4

MySQL 时间戳的格式现在很好

  1. 在页面主体的第一个表格单元格中,将$row['created']更改为$row [ ' date _created'],以匹配 SQL 查询中的别名。

  2. 保存页面并将其加载到浏览器中。现在日期的格式应该如图 16-4 所示。尝试其他说明符来满足您的偏好。

blog_list_mysqli.phpblog_list_pdo.php的更新版本在ch16文件夹中。

添加和减去日期

处理日期时,添加或减去特定的时间段通常很有用。例如,您可能希望显示在过去 7 天内添加到数据库中的项目,或者停止显示 3 个月没有更新的文章。MySQL 通过DATE_ADD()DATE_SUB()让这变得简单。这两个函数都有同义词,分别叫做ADDDATE()SUBDATE()

它们的基本语法都是一样的,如下所示:

DATE_ADD(date, INTERVAL value interval_type)

在使用这些函数时, date 可以是包含您想要更改的日期的列、包含特定日期的字符串(格式为YYYY-MM-DD)或者 MySQL 函数,比如NOW()INTERVAL是一个关键字,后跟一个值和一个区间类型,最常见的列于表 16-2 。

表 16-2

最常用的间隔类型有 DATE_ADD()和 DATE_SUB()

|

区间类型

|

意义

|

值格式

| | --- | --- | --- | | DAY | 天 | 数字 | | DAY_HOUR | 天数和小时数 | 字符串表示为'DD hh' | | WEEK | 周末 | 数字 | | MONTH | 月份 | 数字 | | QUARTER | 四分之一 | 数字 | | YEAR | 年 | 数字 | | YEAR_MONTH | 年和月 | 字符串表示为'YY-MM' |

区间类型是常量,那么而不是DAYWEEK等的末尾加“S”使其成为复数。

这些函数最有用的应用之一是在表格中只显示最近的条目。

PHP 解决方案 16-3:显示上周更新的项目

这个 PHP 解决方案展示了如何根据特定的时间间隔限制数据库结果的显示。使用 PHP 解决方案 16-1 中的blog.php

  1. blog.php中找到 SQL 查询。看起来是这样的:

  2. 像这样改变它:

$sql = 'SELECT * FROM blog ORDER BY created DESC';

        $sql = 'SELECT * FROM blog
WHERE updated > DATE_SUB(NOW(), INTERVAL 1 WEEK)
                   ORDER BY created DESC';

这告诉 MySQL 您只想要在过去一周内更新过的项目。

  1. 在浏览器中保存并重新加载页面。根据您最后一次更新blog表中的项目的时间,您应该看不到任何内容或者看到有限范围的项目。如有必要,将间隔类型更改为DAYHOUR,以测试时间限制是否有效。

  2. 打开blog_list_mysqli.phpblog_list_pdo.php,选择blog.php中没有显示的项目,并进行编辑。重装blog.php。您刚刚更新的项目现在应该会显示出来。

您可以将您的代码与ch16文件夹中的blog_limit_mysqli.phpblog_limit_pdo.php进行比较。

将日期插入 MySQL

MySQL 要求将日期格式化为YYYY-MM-DD格式,这让允许用户输入日期的在线表单感到头疼。正如您在第十五章中看到的,可以使用TIMESTAMP列自动插入当前日期和时间。您还可以使用 MySQL 的NOW()函数在DATEDATETIME列中插入当前日期。当你需要其他日期的时候,问题就出现了。

理论上,HTML5 date输入类型应该已经解决了这个问题。支持日期输入字段的浏览器通常会在字段获得焦点时显示一个日期选择器,并以本地格式插入日期。在ch16文件夹的date_test.php中有一个例子。图 16-5 显示了谷歌 Chrome 如何在我的电脑上以正确的欧洲格式显示日期;但是当提交表单时,值被转换成 ISO 格式。尽管目前使用的绝大多数浏览器都支持date输入字段,但是谨慎对待日期输入字段是明智的。

img/332054_5_En_16_Fig5_HTML.jpg

图 16-5

HTML5 日期输入字段以本地格式显示日期,但以 ISO 格式提交

使用单个日期输入字段依赖于用户的浏览器正确支持 HTML5 日期输入,或者信任用户遵循设定的模式输入日期,例如MM/DD/YYYY。如果每个人都同意,您可以使用explode()功能重新排列日期部分,如下所示:

if (isset($_POST['theDate'])) {
    $date = explode('/', $_POST['theDate']);
    $mysqlFormat = "$date[2]-$date[0]-$date[1]";
}

如果有人偏离了这种格式,您的数据库中就会出现无效的日期。

因此,从在线表单中收集日期的最可靠方法仍然是使用单独的月、日和年输入字段。

PHP 解决方案 16-4:验证和格式化 MySQL 输入的日期

这个 PHP 解决方案专注于检查日期的有效性并将其转换为 MySQL 格式。它被设计成包含在您自己的插入或更新表单中。

  1. 创建一个名为date_converter.php的页面,并插入一个包含以下代码的表单(或者使用ch16文件夹中的date_converter_01.php):
<form method="post" action="date_converter.php">
    <p>
        <label for="month">Month:</label>
        <select name="month" id="month">
            <option value=""></option>
        </select>
        <label for="day">Date:</label>
        <input name="day" type="number" required id="day" max="31" min="1"
            maxlength="2">
        <label for="year">Year:</label>
        <input name="year" type="number" required id="year" maxlength="4">
    </p>
    <p>
        <input type="submit" name="convert" id="convert" value="Convert">
    </p>
</form>

这创建了一个名为month的下拉菜单和两个名为dayyear的输入字段。下拉菜单目前没有任何值,但是它将由一个 PHP 循环填充。dayyear字段使用 HTML5 number类型和required属性。日字段还具有maxmin属性,以便将范围限制在 1 到 31 之间。支持新 HTML5 表单元素的浏览器在字段旁边显示数字步进器,并限制输入的类型和范围。其他浏览器将它们呈现为普通的文本输入字段。为了旧浏览器的利益,两者都有maxlength属性来限制接受的字符数。

  1. 修改构建下拉菜单的部分,如下所示:
<select name="month" id="month">
    <?php
    $months = ['Jan','Feb','Mar','Apr','May','Jun',
        'Jul','Aug', 'Sep', 'Oct', 'Nov','Dec'];
    $thisMonth = date('n');
    for ($i = 1; $i <= 12; $i++) { ?>
        <option value="<?= $i ?>"
            <?php
            if ((!$_POST && $i == $thisMonth) ||
                (isset($_POST['month']) && $i == $_POST['month'])) {
                echo ' selected';
            } ?>>
            <?= $months[$i - 1] ?>
        </option>
    <?php } ?>
</select>

这将创建一个月份名称数组,并使用date()函数来查找当前月份的数字(传递给date()的参数的含义将在本章后面解释)。

然后一个for循环填充菜单的<option>标签。我已经将$i的初始值设置为1,因为我想用它来表示月份的值。在循环内部,条件语句检查两组条件,两组条件都用括号括起来,以确保它们以正确的顺序进行计算。第一组检查$_POST数组是否为空,以及$i$thisMonth的值是否相同。但是如果表单已经提交,$_POST['month']将已经被设置,因此备选条件集检查$i是否与$_POST['month']相同。因此,当第一次加载表单时,selected被插入到当前月份的<option>标记中。但是,如果表单已经提交,则用户选择的月份会再次显示。

通过从$months数组中提取月份名称,月份名称显示在<option>标记之间。因为索引数组从 0 开始,所以需要从$i的值中减去 1 来得到正确的月份。

img/332054_5_En_16_Fig6_HTML.jpg

图 16-6

对日期部分使用单独的输入字段有助于消除错误

  1. 在提交表单后,用当前日期或选择的值填充日期和年份字段:

    <label for="day">Date:</label>
    <input name="day" type="number" required id="day" max="31" min="1"
        maxlength="2" value="<?php if (!$_POST) {
               echo date('j');
           } elseif (isset($_POST['day'])) {
               echo safe($_POST['day']);
           } ?>">
    <label for="year">Year:</label>
    <input name="year" type="number" required id="year" maxlength="4"
           value="<?php if (!$_POST) {
               echo date('Y');
           } elseif (isset($_POST['year'])) {
               echo safe($_POST['year']);
    } ?>">
    
    
  2. 保存页面并在浏览器中测试。它应该显示当前日期,看起来类似于图 16-6 。

如果您测试输入字段,在大多数浏览器中,日期字段应该接受不超过两个字符,年份字段最多四个字符。尽管这降低了出错的可能性,但您仍然需要验证输入并正确格式化日期。

  1. 执行所有检查的代码是utility_funcs.php中的自定义函数。看起来是这样的:
function convertDateToISO(int $month, int $day, int $year) {
    $month = trim($month);
    $day = trim($day);
    $year = trim($year);
    if (empty($month) || empty($day) || empty($year)) {
        throw new Exception('Please fill in all fields');
    } elseif (($month < 1 || $month > 12) || ($day < 1 || $day > 31) || ($year < 1000 ||
        $year > 9999)) {
        throw new Exception('Please use numbers within the correct range');
    } elseif (!checkdate($month,$day,$year)) {
        throw new Exception('You have used an invalid date');
    }
    return sprintf('%d-%02d-%02d', $year, $month, $day);
}

该函数有三个参数:月、日和年。通过使用类型声明,如果使用了错误的输入类型,函数将自动将参数转换为整数。前三行代码修剪输入两端的任何空白。

这一系列条件语句检查输入值,看它们是否为空、不在可接受的范围内或者是否构成无效日期。即使表单已经预先填充了值,也不能保证输入来自您的表单。它可能来自自动化脚本,这就是为什么这些检查是必要的。

年的范围由 MySQL 的合法范围决定。万一您需要超出该范围的年份,您必须选择不同的列类型来存储数据。

如果输入通过了前两次测试,它将接受 PHP 函数checkdate(),该函数足够智能,可以知道何时是闰年,并防止出现类似 9 月 31 日这样的错误。

任何错误都会导致函数抛出异常。但是如果输入通过了所有这些测试,那么在使用sprintf()函数以正确的格式重新构建以插入 MySQL 之后,它将被返回。它将一个格式化字符串作为它的第一个参数,其中%d代表一个整数,%02d代表一个两位数的整数,如果需要,用前导零填充。连字符按字面意思处理。以下三个参数是要放入格式化字符串的值。这将产生 ISO 格式的日期,在月和日前面加零。

Note

sprintf()详见 www.php.net/manual/en/function.sprintf.php

  1. 出于测试目的,将此代码添加到页面主体中的表单下方:
if (isset($_POST['convert'])) {
    try {
        $converted = convertDateToISO($_POST['month'], $_POST['day'],
            $_POST['year']);
        echo 'Valid date: ' . $converted;
    } catch (Throwable $t) {
        echo 'Error: ' . $t->getMessage() . '<br>';
        echo 'Input was: ' . $months[$_POST['month'] - 1] . ' ' .
            safe($_POST['day']) . ', ' . safe($_POST['year']);
    }
}

这将检查表单是否已提交。如果是,它将表单值传递给convertDateToISO()函数,将结果保存在$converted中。因为函数可能抛出一个Exception,所以代码被嵌入在一个try / catch结构中。

如果输入和日期有效,则显示格式化的日期。如果日期不能转换成 ISO 格式,catch块显示存储在Exception中的错误信息,以及原始输入。为了显示正确的月份值,从$_POST['month']的值中减去 1,并将结果用作$months数组的键。$_POST['day']$_POST['year']的值被传递给safe()函数,以防止表单被远程利用。

img/332054_5_En_16_Fig7_HTML.jpg

图 16-7

日期已经过验证并转换为 ISO 格式

  1. 保存页面,并通过输入日期并单击“转换”进行测试。如果日期有效,你应该看到它被转换成 ISO 格式,如图 16-7 所示。

img/332054_5_En_16_Fig8_HTML.jpg

图 16-8

函数的作用是:拒绝无效的日期

  1. 如果您输入了无效的日期,您应该会看到一条适当的消息(参见图 16-8 )。

您可以将您的代码与ch16文件夹中的date_converter_02.php进行比较。

为需要用户输入日期的表格创建表单时,以与date_converter.php相同的方式添加月、日和年三个字段。在将表单输入插入数据库之前,包含utility_funcs.php(或者您决定存储该函数的任何地方),并使用convertDateToISO()函数来验证日期并将其格式化以便插入数据库:

require_once 'utility_funcs.php';
try {
    $date = convertDateToMySQL($_POST['month'], $_POST['day'], $_POST['year']);
} catch(Throwable $t) {
    $errors[] = $t->getMessage();
}

如果您的$errors数组有任何元素,放弃插入或更新过程并显示错误。否则,在 SQL 查询中插入$date是安全的。

Note

本章的其余部分将致力于在 PHP 中处理日期。这是一个重要但复杂的课题。我建议您浏览每一节以熟悉 PHP 的日期处理功能,并在需要实现特定功能时返回本节。

在 PHP 中使用日期

PHP 与其他计算机语言一样,通过从 Unix 纪元,即 1970 年 1 月 1 日午夜 UTC(协调世界时)开始以秒计算来处理复杂的日期和时间。幸运的是,PHP 通过它的DateTimeDateTimeZoneDateIntervalDatePeriod类在后台完成了大部分艰苦的工作。基本操作由简单的函数处理。

可用日期的范围取决于 PHP 的编译方式。DateTime和相关的类在内部将日期和时间信息存储为 64 位数字,这使得表示从过去大约 2920 亿年到未来相同数量年的日期成为可能。但是,如果 PHP 是在 32 位处理器上编译的,那么表 16-3 的后半部分的函数就被限制在大约 1901 年到 2038 年 1 月的范围内。

表 16-3 总结了 PHP 中与日期和时间相关的主要类和函数。

表 16-3

PHP 日期和时间相关的类和函数

|   |

名字

|

争论

|

描述

| | --- | --- | --- | --- | | 班级 |   |   |   | |   | DateTime | 日期字符串,DateTimeZone对象 | 创建一个区分时区的对象,包含可用于日期和时间计算的日期和/或时间信息。 | |   | DateTimeImmutable | 同DateTime | 与DateTime相同,但是改变任何值都会返回一个新的对象,原始对象保持不变。 | |   | DateTimeZone | 时区字符串 | 存储用于DateTime对象的时区信息。 | |   | DateInterval | 区间说明 | 以年、月、小时等表示固定的时间量。 | |   | DatePeriod | 开始,间隔,结束/重复,选项 | 计算一段时间内的重复日期或重复次数。 | | 功能 |   |   |   | |   | time() | 没有人 | 为当前日期和时间生成 Unix 时间戳。 | |   | mktime() | 小时、分钟、秒、月、日、年 | 为指定的日期/时间生成 Unix 时间戳。 | |   | strtotime() | 日期字符串,时间戳 | 尝试从英文文本描述中生成 Unix 时间戳,例如“next Tuesday”返回值相对于第二个参数(如果提供的话)。 | |   | date() | 格式字符串,时间戳 | 使用表 16-4 中列出的说明符格式化英文日期。如果省略第二个参数,则使用当前日期和时间。 | |   | strftime() | 格式字符串,时间戳 | 与date()相同,但使用系统区域设置指定的语言。 |

设置默认时区

PHP 中的所有日期和时间信息都是根据服务器的默认时区设置存储的。web 服务器与您的目标受众位于不同的时区是很常见的,因此了解如何更改默认设置是很有用的。

服务器的默认时区通常应该在php.inidate.timezone指令中设置,但是如果你的托管公司忘记这样做或者你想使用不同的时区,你需要自己设置。

如果你的托管公司让你控制你自己版本的php.ini,在那里改变date.timezone的值。这样,它会自动为您的所有脚本设置。

如果您的服务器支持.htaccess.user.ini文件,您可以通过在站点根目录中添加适当的命令来更改时区。对于.htaccess,用这个:

php_value date.timezone 'timezone'

对于.user.ini,命令如下所示:

date.timezone=timezone

时区替换为您所在位置的正确设置。您可以在 www.php.net/manual/en/timezones.php 找到有效时区的完整列表。

如果这些选项都不可用,请在任何使用日期或时间函数的脚本的开头添加以下内容(用适当的值替换时区):

ini_set('date.timezone', 'timezone');

创建日期时间对象

要创建一个DateTime对象,只需使用new关键字后跟DateTime(),就像这样:

$now = new DateTime();

这将创建一个对象,该对象根据 web 服务器的时钟和默认时区设置来表示当前日期和时间。

DateTime()构造函数还接受两个可选参数:一个包含日期和/或时间的字符串和一个DateTimeZone对象。第一个参数的日期/时间字符串可以是 www.php.net/manual/en/datetime.formats.php 中列出的任何格式。与只接受一种格式的 MySQL 不同,PHP 走向了相反的极端。例如,要为 2021 年圣诞节创建一个DateTime对象,以下所有格式都有效:

'12/25/2021'
'25-12-2021'
'25 Dec 2021'
'Dec 25 2021'
'25-XII-2021'
'25.12.2021'
'2021/12/25'
'2021-12-25'
'December 25th, 2021'

这不是一份详尽的清单。它只是有效格式的选择。潜在的混乱出现在分隔符的使用上。例如,在美式日期(12/25/2021)和 ISO 日期(2021/12/25)中允许使用正斜杠,但在日期以欧洲顺序显示或月份由罗马数字表示时则不允许。要以欧洲顺序显示日期,分隔符必须是点、制表符或破折号。

也可以使用相对表达式来指定日期,例如“下周三”、“明天”或“上周一”然而,这里也存在潜在的混乱。有些人用“下周三”来表示“下周三”PHP 从字面上解释这个表达式。如果今天是星期二,“下星期三”意味着第二天。

您不能单独使用echo来显示存储在DateTime对象中的值。除了echo,您需要告诉 PHP 如何使用format()方法格式化输出。

用 PHP 格式化日期

DateTime类的format()方法使用与date()函数相同的格式字符。虽然这有助于保持连续性,但格式字符通常很难记住,而且似乎背后没有明显的逻辑。表 16-4 列出了最有用的日期和时间格式字符。

DateTime类和date()函数只用英语显示工作日和月份的名称,但是strftime()函数使用服务器的语言环境指定的语言。因此,如果服务器的地区设置为西班牙语,那么DateTime对象和date()显示星期六,但是strftime()显示萨巴多。除了DateTime类和date()函数使用的格式字符,表 16-4 列出了strftime()使用的等效字符。不是所有的格式在strftime()中都有对应的。

表 16-4

主要日期和时间格式字符

|

单位

|

日期时间/日期( )

|

strftime( )

|

描述

|

例子

| | --- | --- | --- | --- | --- | | 一天 | D | %d | 以零开头的一个月中的某一天 | 01–31 | |   | J | %e * | 不带前导零的一月中的某一天 | 1–31 | | S |   | 表示一个月中某一天的英语序数后缀 | 第一、第二、第三或第四 | | D | %a | 日期名称的前三个字母 | 星期二,星期日 | | l(小写“L”) | %A | 一天的全名 | 星期天,星期二 | | 月 | M | %m | 带前导零的月份数 | 01–12 | |   | N |   | 不带前导零的月份数 | 1–12 | | M | %b | 月份名称的前三个字母 | 珍,Jul | | F | %B | 月份的全名 | 一月,七月 | | 年 | Y | %Y | 以四位数显示的年份 | Two thousand and fourteen | | y | %y | 以两位数显示的年份 | Fourteen | | 小时 | g |   | 不带前导零的 12 小时制小时 | 1–12 | | h | %I | 带有前导零的 12 小时格式的小时 | 01–12 | | G |   | 24 小时格式的小时,不带前导零 | 0–23 | | H | %H | 带有前导零的 24 小时制小时 | 01–23 | | 分钟 | i | %M | 分钟,如有必要,带前导零 | 00–59 | | 秒 | s | %S | 秒,如有必要,带前导零 | 00–59 | | 上午/下午 | a |   | 小写字母 | 是 | | 上午/下午 | A | %p | 大写字母 | 首相 |

*注意:Windows 不支持%e。

您可以根据自己的喜好将这些格式字符与标点符号结合起来,在网页上显示当前日期。

要格式化一个DateTime对象,将格式字符串作为参数传递给format()方法,如下所示(代码在ch16文件夹的date_format_01.php中):

<?php
$now = new DateTime();
$xmas2021 = new DateTime('12/25/2021');
?>
<p>It's now <?= $now->format('g.ia') ?> on <?= $now->format('l, F jS, Y') ?></p>
<p>Christmas 2021 falls on a <?= $xmas2021->format('l') ?></p>

在这个例子中,创建了两个DateTime对象:一个用于当前日期和时间,另一个用于 2021 年 12 月 25 日。使用表 16-4 中的格式字符,从两个对象中提取不同的日期部分,产生如下截图所示的输出:

img/332054_5_En_16_Figa_HTML.jpg

date_format_02.php中的代码通过使用date()strtotime()函数产生相同的输出,如下所示:

<?php $xmas2021 = strtotime('12/25/2021') ?>
<p>It's now <?= date('g.ia') ?> on <?= date('l, F jS, Y') ?></p>
<p>Christmas 2021 falls on a <?= date('l', $xmas2021) ?></p>

第一行使用strtotime()创建 2021 年 12 月 25 日的时间戳。不需要为当前日期和时间创建时间戳,因为在没有第二个参数的情况下使用时,date()默认为当前日期和时间。

如果圣诞节的时间戳没有在脚本的其他地方使用,第一行可以省略,对date()的最后一次调用可以重写为这样(参见date_format_03.php):

date('l', strtotime('12/25/2021'))

从自定义格式创建日期时间对象

您可以使用表 16-4 中的格式字符为DateTime对象指定一个自定义输入格式。不是用new关键字创建对象,而是使用createFromFormat()静态方法,就像这样:

$date = DateTime::createFromFormat(format_string, input_date, timezone);

第三个参数,时区,是可选的。如果包含的话,应该是一个DateTimeZone对象。

一个静态方法属于整个类,而不是某个特定的对象。使用类名后跟范围解析操作符(双冒号)和方法名来调用静态方法。

Tip

在内部,作用域解析操作符被称为PAAMAYIM_NEKUDOTAYIM,希伯来语是“双冒号”的意思。为什么是希伯来语?为 PHP 提供动力的 Zend 引擎最初是由 Zeev Suraski 和 Andi Gutmans 开发的,当时他们还是以色列技术学院的学生。除了在极客问答游戏中获得分数之外,当你在 PHP 错误消息中看到PAAMAYIM_NEKUDOTAYIM时,知道它的意思可以让你省去很多挠头的麻烦。

例如,您可以使用createFromFormat()方法接受以欧洲格式表示的日、月、年的日期,用斜线分隔,就像这样(代码在date_format_04.php中):

$xmas2021 = DateTime::createFromFormat('d/m/Y', '25/12/2021');
echo $xmas2021->format('l, jS F Y');

这会产生以下输出:

img/332054_5_En_16_Figb_HTML.jpg

Caution

试图将 2021 年 12 月 25 日用作DateTime构造函数的输入会触发致命错误,因为不支持DD/MM/YYYY。如果您想使用一种不被DateTime构造函数支持的格式,您必须使用createFromFormat()静态方法。

虽然createFromFormat()方法很有用,但它只能在你知道日期总是特定格式的情况下使用。

在 date()和 DateTime 类之间选择

当显示日期时,使用DateTime类总是一个两步过程。在调用format()方法之前,需要实例化对象。通过date()功能,你可以一次完成。因为它们都使用相同的格式字符,所以在处理当前日期和/或时间时,date()轻而易举地胜出。

Tip

从技术上讲,通过将对象的创建放在一对括号中,可以在实例化一个DateTime对象的同时调用format()方法。但是使用date()要简单得多。您可以在date_format_05.php中比较两种显示日期的方法。

对于简单的任务,如显示当前日期、时间或年份,使用date()。当使用表 16-5 中列出的方法处理与日期相关的计算和时区时,DateTime类开始发挥作用。

表 16-5

主要的日期时间方法

|

方法

|

争论

|

描述

| | --- | --- | --- | | format() | 格式字符串 | 使用表 16-4 中的格式字符格式化日期/时间。 | | setDate() | 年、月、日 | 更改日期。参数应该用逗号分隔。超出允许范围的月份或天数将被添加到结果日期中,如正文中所述。 | | setTime() | 小时、分钟、秒 | 重置时间。参数是逗号分隔的值。秒是可选的。超出允许范围的值将被添加到结果日期/时间中。 | | modify() | 相对日期字符串 | 使用相对表达式更改日期/时间,如“+2 周”。 | | getTimestamp() | 没有人 | 返回日期/时间的 Unix 时间戳。 | | setTimestamp() | Unix 时间戳 | 根据 Unix 时间戳设置日期/时间。 | | setTimezone() | DateTimeZone对象 | 更改时区。 | | getTimezone() | 没有人 | 返回一个代表DateTime对象时区的DateTimeZone对象。 | | getOffset() | 没有人 | 返回相对于 UTC 的时区偏移量,以秒为单位。 | | add() | DateInterval对象 | 按设定的周期增加日期/时间。 | | sub() | DateInterval对象 | 从日期/时间中减去设定的时间段。 | | diff() | DateTime对象,布尔型 | 返回一个代表当前DateTime对象和作为参数传递的对象之间差异的DateInterval对象。使用true作为可选的第二个参数将负值转换为正的等值。 |

setDate()setTime()添加超出范围的值会导致超出部分被添加到结果日期或时间中。例如,使用 14 作为月份会将日期设置为下一年的二月。将小时设置为 26 会导致第二天凌晨 2 点。

使用setDate()的一个有用技巧是,通过将月份值设置为下个月,将日期设置为 0,可以将日期设置为任意一个月的最后一天。setDate.php中的代码用 2022 年和 2024 年(闰年)2 月的最后一天证明了这一点:

<?php
$format = 'F j, Y';
$date = new DateTime();
$date->setDate(2022, 3, 0);
?>
<p>Non-leap year: <?= $date->format($format) ?>.</p>
<p>Leap year: <?php $date->setDate(2024, 3, 0);
    echo $date->format($format); ?>.</p>

前面的示例产生以下输出:

img/332054_5_En_16_Figc_HTML.jpg

用相对日期处理溢出

modify()方法接受相对日期字符串,这可能会产生意想不到的结果。例如,如果将一个月添加到代表 2022 年 1 月 31 日的DateTime对象中,得到的值不是 2 月的最后一天,而是 3 月 3 日。

发生这种情况是因为在原始日期上加一个月会得到 2 月 31 日,但在非闰年中 2 月只有 28 天。因此,超出范围的值被添加到月份中,结果为 3 月 3 日。如果您随后从同一个DateTime对象中减去一个月,它会将您带回到 2 月 3 日,而不是最初的开始日期。date_modify_01.php中的代码说明了这一点,如图 16-9 所示:

img/332054_5_En_16_Fig9_HTML.jpg

图 16-9

加减月份会导致意想不到的结果

<?php
$format = 'F j, Y';
$date = new DateTime('January 31, 2022');
?>
<p>Original date: <?= $date->format($format) ?>.</p>
<p>Add one month: <?php
$date->modify('+1 month');
echo $date->format($format);
$date->modify('-1 month');
?>
<p>Subtract one month: <?= $date->format($format) ?>

避免这个问题的方法是在相对表达式中使用'last day of',像这样(代码在date_modify_02.php中):

<?php
$format = 'F j, Y';
$date = new DateTime('January 31, 2022');
?>
<p>Original date: <?= $date->format($format) ?>.</p>
<p>Add one month: <?php
    $date->modify('last day of +1 month');
    echo $date->format($format);
    $date->modify('last day of -1 month');
    ?>
<p>Subtract one month: <?= $date->format($format) ?>

如图 16-10 所示,这就产生了想要的结果。

img/332054_5_En_16_Fig10_HTML.jpg

图 16-10

在相对表达式中使用“最后一天”可以解决这个问题

使用 DateTimeZone 类

一个DateTime对象自动使用 web 服务器的默认时区,除非您已经使用前面描述的方法之一重置了时区。然而,您可以通过构造函数可选的第二个参数或者通过使用setTimezone()方法来设置单个DateTime对象的时区。在这两种情况下,参数必须是一个DateTimeZone对象。

要创建一个DateTimeZone对象,将 www.php.net/manual/en/timezones.php 中列出的一个支持的时区作为参数传递给构造函数,如下所示:

$UK = new DateTimeZone('Europe/London');
$USeast = new DateTimeZone('America/New_York');
$Hawaii = new DateTimeZone('Pacific/Honolulu');

当检查支持的时区列表时,重要的是要认识到它们是基于地理区域和城市,而不是基于官方时区。这是因为 PHP 自动将夏令时考虑在内。不使用夏令时的亚利桑那州被America/Phoenix覆盖。

将时区组织成地理区域会带来一些惊喜。美洲不是指美国,而是南北美洲和加勒比海的大陆。因此,檀香山不在美国列出,而是作为一个太平洋时区。欧洲也指欧洲大陆,包括不列颠群岛和爱尔兰,但不包括其他岛屿。所以雷克雅未克和马德拉被列为大西洋时区,而挪威斯瓦尔巴特岛上的朗伊尔城享有唯一的北极时区的特权。

timezones.php中的代码为伦敦、纽约和檀香山创建DateTimeZone对象,然后使用第一个对象初始化一个DateTime对象,如下所示:

$now = new DateTime('now', $UK);

使用echoformat()方法显示日期和时间后,使用setTimezone()方法更改时区,如下所示:

$now->setTimezone($USeast);

下次显示$now时,它显示纽约的日期和时间。最后,再次使用setTimezone()将时区更改为檀香山,产生以下输出:

img/332054_5_En_16_Figd_HTML.jpg

Caution

时区转换的准确性取决于编译到 PHP 中的时区数据库是否是最新的。

要找到服务器的时区,可以检查php.ini或者使用带有DateTime对象的getTimezone()方法。getTimezone()方法返回一个DateTimeZone对象,而不是一个包含时区的字符串。要获得时区的值,您需要使用DateTimeZone对象的getName()方法,就像这样(代码在timezone_display.php中):

$now = new DateTime();
$timezone = $now->getTimezone();
echo $timezone->getName();

DateTimeZone类有几个公开时区信息的其他方法。为了完整起见,它们被列在表 16-6 中,但是DateTimeZone类的主要用途是为DateTime对象设置时区。

表 16-6

DateTimeZone 方法

|

方法

|

争论

|

描述

| | --- | --- | --- | | getLocation() | 没有人 | 返回包含国家代码、纬度、经度和时区注释的关联数组。 | | getName() | 没有人 | 返回包含时区的地理区域和城市的字符串。 | | getOffset() | DateTime对象 | 计算作为参数传递的DateTime对象相对于 UTC 的偏移量(秒)。 | | getTransitions() | 开始,结束 | 返回一个多维数组,其中包含夏令时的历史和未来切换日期和时间。接受两个时间戳作为可选参数来限制结果的范围。 | | listAbbreviations() | 没有人 | 生成一个大型多维数组,包含 PHP 支持的 UTC 偏移量和时区名称。 | | listIdentifiers() | DateTimeZone常量,国家代码 | 返回所有 PHP 时区标识符的数组,如欧洲/伦敦、美国/纽约等。接受两个可选参数来限制结果的范围。使用 www.php.net/manual/en/class.datetimezone.php 中列出的DateTimeZone常量之一作为第一个参数。如果第一个参数是DateTimeZone::PER_COUNTRY,那么可以使用两个字母的国家代码作为第二个参数。 |

表 16-6 中的最后两个方法是静态方法。通过使用范围解析运算符直接在类上调用它们,如下所示:

$abbreviations = DateTimeZone::listAbbreviations();

用 DateInterval 类添加和减去设定的周期

使用add()sub()方法,DateInterval类用于指定从DateTime对象中增加或减少的周期。它也被返回一个DateInterval对象的diff()方法使用。一开始使用DateInterval类感觉很奇怪,但是理解起来相对简单。

要创建一个DateInterval对象,需要向构造函数传递一个指定区间长度的字符串;该字符串必须根据 ISO 8601 标准进行格式化。该字符串总是以字母P(代表句点)开头,后跟一对或多对整数和字母,称为句点标志符。如果时间间隔包括小时、分钟或秒,时间元素前面会有字母T。表 16-7 列出了有效的周期指示器。

表 16-7

DateInterval 类使用的 ISO 8601 时段指示符

|

周期指示符

|

意义

| | --- | --- | | Y | 年 | | M | 月份 | | W | 周—不能与日结合使用 | | D | 天—不能与周结合使用 | | H | 小时 | | M | 分钟 | | S | 秒 |

以下示例将阐明如何指定时间间隔:

$interval1 = new DateInterval('P2Y');           // 2 years
$interval2 = new DateInterval('P5W');           // 5 weeks
$interval3 = new DateInterval('P37D');          // 5 weeks 2 days
$interval4 = new DateInterval('PT6H20M');       // 6 hours 20 minutes
$interval5 = new DateInterval('P1Y2DT3H5M50S'); // 1 year 2 days 3 hours 5 min 50 sec

注意$interval3需要指定总天数,因为周会自动转换为天,所以WD不能组合在同一个区间定义中。

要将DateInterval对象与DateTime类的add()sub()方法一起使用,请将该对象作为参数传递。例如,这会将 2021 年圣诞节的日期增加 12 天:

$xmas2021 = new DateTime('12/25/2021');
$interval = new DateInterval('P12D');
$xmas2021->add($interval);

如果不需要重用区间,可以直接将DateInterval构造函数作为参数传递给add(),如下所示:

$xmas2021 = new DateTime('12/25/2021');
$xmas2021->add(new DateInterval('P12D'));

该计算的结果在date_interval_01.php中显示,产生以下输出:

img/332054_5_En_16_Fige_HTML.jpg

除了使用表 16-7 中列出的周期指示器之外,还可以使用静态createFromDateString()方法,该方法以与strtotime()相同的方式将英文相对日期字符串作为参数。使用createFromDateString(),前面的例子可以改写成这样(代码在date_interval_02.php):

$xmas2021 = new DateTime('12/25/2021');
$xmas2021->add(DateInterval::createFromDateString('+12 days'));

这产生了完全相同的结果。

Caution

DateInterval加减月份的效果和前面描述的一样。如果结果日期超出范围,则增加额外的天数。例如,将 1 月 31 日加上一个月会得到 3 月 3 日或 3 月 2 日,这取决于是否是闰年。要获得一个月的最后一天,请使用前面“用相对日期处理溢出”中描述的技术

用 diff()方法找出两个日期之间的差异

为了找出两个日期之间的差异,为两个日期创建一个DateTime对象,并将第二个对象作为参数传递给第一个对象的diff()方法。结果作为一个DateInterval对象返回。要从DateInterval对象中提取结果,需要使用该对象的format()方法,该方法使用表 16-8 中列出的格式字符。这些不同于DateTime类使用的格式字符。幸运的是,大多数都很容易记住。

表 16-8

DateInterval format()方法使用的格式字符

|

格式符

|

描述

|

例子

| | --- | --- | --- | | %Y | 几年了。至少两位数,必要时带前导零 | 12, 01 | | %y | 年份,无前导零 | 12, 1 | | %M | 带前导零的月份 | 02, 11 | | %m | 月份,没有前导零 | 2, 11 | | %D | 带前导零的天数 | 03, 24 | | %d | 天,没有前导零 | 3, 24 | | %a | 总天数 | 15, 231 | | %H | 带前导零的小时 | 03, 23 | | %h | 小时,无前导零 | 3, 23 | | %I | 带前导零的分钟 | 05, 59 | | %i | 分钟,无前导零 | 5, 59 | | %S | 带前导零的秒 | 05, 59 | | %s | 秒,没有前导零 | 5, 59 | | %R | 负数时显示减号,正数时显示加号 | -, + | | %r | 负数时显示减号,正数时不显示符号 | - | | %% | 百分比符号 | % |

date_interval_03.php中的以下示例显示了如何使用diff()获取当前日期和美国独立宣言之间的差异,并使用format()方法显示结果:

<p><?php
$independence = new DateTime('7/4/1776');
$now = new DateTime();
$interval = $now->diff($independence);
echo $interval->format('%Y years %m months %d days'); ?>
since American Declaration of Independence.</p>

如果你把date_interval_03.php加载到一个浏览器中,你应该会看到类似下面截图的东西(当然实际的时间段会有所不同):

img/332054_5_En_16_Figf_HTML.jpg

格式字符遵循一种逻辑模式。大写字符总是产生至少两位数,必要时带有前导零。小写字符没有前导零。

Caution

除了代表总天数的%a之外,格式字符仅代表整个时间间隔的特定部分。例如,如果您将格式字符串更改为$interval->format('%m months'),它将只显示自去年 7 月 4 日以来已经过去的整月数。它不显示自 1776 年 7 月 4 日以来的总月数。

使用 DatePeriod 类计算重复日期

多亏了DatePeriod类,计算出重复的日期,比如每个月的第二个星期二,现在变得非常容易。它与一个DateInterval协同工作。

DatePeriod构造函数的不同寻常之处在于它以三种不同的方式接受参数。创建DatePeriod对象的第一种方法是提供以下参数:

  • 一个代表开始日期的DateTime对象

  • 表示重复间隔的DateInterval对象

  • 表示重复次数的整数

  • DatePeriod::EXCLUDE_START_DATE常量(可选)

一旦创建了一个DatePeriod对象,就可以使用DateTime format()方法在一个foreach循环中显示重复出现的日期。

date_interval_04.php中的代码显示 2022 年每个月的第二个星期二:

$start = new DateTime('12/31/2021');
$interval = DateInterval::createFromDateString('second Tuesday of next month');
$period = new DatePeriod($start, $interval, 12, DatePeriod::EXCLUDE_START_DATE);
foreach ($period as $date) {
   echo $date->format('l, F jS, Y') . '<br>';
}

它产生如图 16-11 所示的输出。

img/332054_5_En_16_Fig11_HTML.jpg

图 16-11

使用 DatePeriod 类计算重复日期非常简单

PHP 代码的第一行将开始日期设置为 2021 年 12 月 31 日。下一行使用DateInterval静态方法createFromDateString()设置下个月第二个星期二的间隔。这两个值都被传递给DatePeriod构造函数,同时传递的还有 12(循环次数)和DatePeriod::EXCLUDE_START_DATE常量。常量的名称是不言自明的。最后,foreach循环使用DateTime format()方法显示结果日期。

创建DatePeriod对象的第二种方法是用表示结束日期的DateTime对象替换第三个参数中的重复次数。date_interval_05.php的代码被修改成这样:

$start = new DateTime('12/31/2021');
$interval = DateInterval::createFromDateString('second Tuesday of next month');
$end = new DateTime('12/31/2022');
$period = new DatePeriod($start, $interval, $end, DatePeriod::EXCLUDE_START_DATE);
foreach ($period as $date) {
    echo $date->format('l, F jS, Y') . '<br>';
}

这产生了与图 16-11 所示完全相同的输出。

您还可以使用 ISO 8601 循环时间间隔标准( https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals )创建一个DatePeriod对象。这不是用户友好的,主要是因为需要以正确的格式构造一个字符串,如下所示:

Rn/YYYY-MM-DDTHH:MM:SStz/Pinterval

R n 是字母R后跟循环次数; tz 是相对于 UTC 的时区偏移量(或Z表示 UTC,如下例所示);并且P 区间使用与DateInterval类相同的格式。date_interval_06.php中的代码显示了如何使用DatePeriod和 ISO 8601 循环间隔的示例。看起来是这样的:

$period = new DatePeriod('R4/2021-06-19T00:00:00Z/P10D');
foreach ($period as $date) {
    echo $date->format('l, F j, Y') . '<br>';
}

ISO 重复间隔设置从 UTC 2021 年 6 月 19 日午夜开始的四次重复,间隔 10 天。重复发生在原始日期之后,因此前面的示例生成了五个日期,如以下输出所示:

img/332054_5_En_16_Figg_HTML.jpg

章节回顾

这一章的很大一部分是关于强大的日期和时间类的。我还没有介绍过DateTimeImmutable类,因为除了一点之外,它在各个方面都与DateTime相同。一个DateTimeImmutable物体从不修改自己。相反,它总是返回一个带有修改值的新对象。如果你有一个永远不变的日期,比如一个人的出生日期,这就很有用。对这种类型的对象使用setDate()add()方法将返回一个新对象,保留原始细节并为更新的细节提供一个新对象,如开始工作、结婚、退休年龄等等。

您可能不需要每天都学习与日期和时间相关的课程,但是它们非常有用。MySQL 的日期和时间函数也使得格式化日期和基于时间标准执行查询变得容易。

也许日期的最大问题是决定是使用 SQL 还是 PHP 来处理格式和/或计算。PHP DateTime类的一个有用特性是构造函数接受以 ISO 格式存储的日期,因此您可以使用数据库中的无格式日期或时间戳来创建DateTime对象。然而,除非您需要执行进一步的计算,否则将DATE_FORMAT()函数作为SELECT查询的一部分会更有效。

本章还提供了三个格式化文本和日期的函数。在下一章中,您将学习如何在多个数据库表中存储和检索相关信息。