创建多项选择测验系统,第 2 部分

作者:Reuven M. Lerner

两个月前,我们开始用 Perl 编写一个简单的多项选择测验引擎。实际上,该专栏几乎全部涵盖了引擎的细节——创建 QuizQuestions 对象以及使用该对象的程序来创建一个简单的多项选择测验,并检查其答案。

最终结果是两个 CGI 程序。第一个程序 askquestion.pl 创建了一个 QuizQuestions 实例,并使用它来选择一个随机问题,然后将其转换为 HTML 表单发送到用户的浏览器。

另一个程序 checkanswer.pl 接受用户提交的此表单,然后检查用户是否选择了正确的答案。

QuizQuestions 对象更重要的是“测验文件”,这是一个 ASCII 文本文件,包含三种不同类型的项目

  • 以井号字符 (#) 开头的注释。注释会被测验引擎忽略。因此,问题不能以 # 开头,但我们可以在问题或答案中使用 #,而不必担心问题的结尾会被截断。

  • 空白字符,例如空格、制表符和回车符,也会被忽略。我们允许使用空白字符,因为用户无疑会用空行分隔测验文件中的项目,例如,我们不需要他们注释掉这些行。

  • 问题记录包含测验的问题和答案。每条记录包含问题的文本,后跟四个可能的答案,然后是 A、B、C 或 D,表示正确答案。每条记录中的字段(问题、答案 1、答案 2、答案 3、答案 4 和正确答案)用制表符分隔,因此,问题和答案都不能包含制表符。

测试用户 GNU Emacs 文本编辑器知识的示例测验文件如清单 1所示。虽然这在纸上可能不明显,但重要的是要记住,每行中的字段用制表符分隔,而不是空格。

原始测验系统的主要缺陷之一是它依赖于用户创建符合这些标准的测验文件的能力。此外,QuizQuestions 对象在读取测验文件时没有检查格式错误。

本月,我们将探讨如何在 CGI 标准的范围内使测验系统更加健壮。

错误检查

首先,我们将修改 QuizQuestions 的定义,以便它在加载测验文件时检查错误。我们应该让它检查哪些类型的错误?一个简单的测试确保每个非注释、非空白行包含恰好六个字段(一个问题、四个答案和一个答案键)。字段数量不同的行将被标记为错误。

清单 2

QuizQuestions.pm 的原始版本如清单 2 所示。为了确保测验文件正确,我们必须修改从测验文件读取的方法——在这种特定情况下,这意味着 new 方法,QuizQuestions 的构造函数。我们可以使用以下行创建 QuizQuestions 的新实例

my $quiz = new QuizQuestions("emacs");

在我们决定如何检查测验文件中的错误之前,我们应该考虑如何报告错误。如果 QuizQuestions.pm 中的方法在测验文件中发现错误,该方法是否应该生成一个 HTML 响应供用户查看?它应该失败,调用 die 并在 HTTP 服务器的错误日志中指示错误吗?它应该两者都做吗?

我建议 QuizQuestions.pm 不应该使用这些选项中的任何一个,因为两者都违反了我们创建的抽象。QuizQuestions 是一个用于轻松操作测验文件中问题的对象,并且“不知道”它是否正在 CGI 程序中使用。QuizQuestions 中的方法应该在发生错误时向调用程序报告错误,而不是直接向用户报告。

如果我们使用像 Java 这样的包含广泛异常处理机制的语言,这将是使用它的绝佳时机;我们不希望调用例程接收一个可能被误解为 $quiz 合法值的返回值。同时,我们确实希望返回有关发生的任何错误的信息。

Perl 的异常处理不如 Java 那样广泛。幸运的是,Perl 确实允许将各种类型的数据分配给同一个运算符。在这种情况下,如果文件没有错误,new 将返回 QuizQuestions 的新实例。如果文件中有错误,new 将返回一个字符串,该字符串由包含错误的行组成。在这种情况下,它可以简单地返回 0;但是,由于我们可以灵活地返回任何标量值,因此最好返回一个编码更多信息的值。

现在我们已经确定错误消息将发回给调用方法,让我们考虑如何确定测验文件中的哪些行包含错误。幸运的是,这是一个容易解决的问题,因为测验文件的每一行非注释、非空行都应该包含恰好六个制表符分隔的字段。因此,如果一行不是注释,不是空行,并且不包含六个字段,那么它一定是错误,并且应该生成一个错误值。

以下是 QuizQuestions 对象内部 new 的现有版本中的循环,该循环从磁盘加载测验文件

# Loop through the question file while (<QUESTIONS>)
{
   next if /^#/;      # Ignore comment lines
   next unless /\w/;  # Ignore whitespace lines
   chomp;
   # Add this question to the list.
   $questions[$counter++] = $_;
}

为了检查错误,我们只需使用 split 运算符将每一行分解为组成字段,并计算列表元素的数量。如果该数字不是六,那么我们有一个语法错误要报告,方法是将有问题的字符串返回给调用例程。以下是上述循环的修改版本,它实现了此策略

# Loop through the question fil,e
while (<QUESTIONS>)
 {
    next if /^#/;      # Ignore comment lines
    next unless /\w/;  # Ignore whitespace lines
    chomp;
    # Split the line across tabs
    my @list = split(/  /);
    # Check to make sure that there are six fields
    if ($#list != 5)
    {
        # Return the line containing the error
        return $_;
    }
    else
    {
        # Add this question to the list
        $questions[$counter++] = $_;
    }
}
此代码与原始 while 循环相同,只有一个区别。在将当前行 $_ 添加到 @questions(一个包含测验文件中的问题和答案的数组)之前,我们在每个制表符处拆分它,创建一个列表,其中测验文件中的每个字段有一个元素。如果列表包含六个元素,那么测验文件的这一行是可以接受的,我们继续使用原始版本的 new——将当前行添加到 @questions,递增 $counter,然后移动到文件的下一行。

如果列表包含六个字段,则该行显然包含错误。当我们执行此测试时,我们已经排除了当前行可能是注释或仅包含空白字符的可能性。

但是等一下——调用者期望接收类型为 QuizQuestions 的对象作为返回。由于 QuizQuestions 对象可以返回多种不同类型的标量数据,我们必须确保调用者可以确定方法调用是成功(即,返回了一个对象)还是失败(即,返回了一个字符串)。

在这种情况下,我们使用 Perl 的 ref 运算符来查找标量是否是对对象的引用以及它是哪种类型的对象。在非对象标量上调用 ref 返回一个空字符串,这使得此类测试变得容易。因此,在上述版本的 new 中,我们可以使用以下代码创建 QuizQuestions 的实例

my $questions = new QuizQuestions("emacs");
&log_and_die($questions) unless (ref($questions)
         eq "

第二行检查 $questions 是否是 QuizQuestions 的实例。如果不是,我们调用 &log_and_die,这是一个例程(包含在清单 5 中),它提供了比简单调用 die 更好的错误日志记录。

虽然此代码有效,但它会创建一个设计不良的对象。毕竟,为什么要编写构造函数,以便调用者必须测试它返回的对象的类型?更好的解决方案是将 new 设为极简主义的创建方法,并将测验文件加载机制放入另一个名为 loadFile 的方法中。然后,此新方法可以返回 0 表示没有错误,或者返回包含错误行的字符串。

有了这些方法,我们编写

my $questions = new QuizQuestions("emacs");
my $error = $questions->loadFile;
&log_and_die($error) if $error;

此代码使用 new 运算符创建 QuizQuestions 的实例,该运算符仅执行最基本的操作。我们使用 loadFile 方法加载测验文件。loadFile 方法返回 0,表示文件已成功加载,或者返回包含导致问题的行的文本字符串。

由于我们修改了 loadFile 以处理错误,我已经将原始的 die 用法(如前所述,在低级对象中不合适)替换为对 return 的调用。

newloadFile 的重写版本如清单 3所示。

创建测验文件

到目前为止,我们已经讨论了 QuizQuestions 对象如何处理测验文件中的语法错误。但是,许多语法错误只是由于错误或用户不熟悉定义的文件格式而创建的。

一种解决方案是为用户提供工具,用于创建错误更少的测验文件。考虑到我们花费大量时间编写 CGI 程序和 HTML 表单,创建一个小程序来获取 HTML 表单的内容并将其保存到磁盘是有意义的。

此类表单的一个示例在清单 4中显示。提交后,表单的内容将传递给 create-quizfile.pl,然后 create-quizfile.pl 创建一个格式正确的测验文件。

为了实现此功能,我们需要向 QuizQuestions 添加两个新方法。一个方法 addQuestion 接受一个六元素列表,并将其添加到 questions,即包含测验文件字段的实例变量。第二个方法 saveFile 执行与 loadFile 相反的操作,获取当前问题并保存它们。

以下是 addQuestion 的一种可能的实现

sub addQuestion
{
    # Get ourselves
    my $self = shift;
    # Get our arguments
    my ($question, $a1, $a2, $a3, $a4,
                    $correct) = @_;
    # Turn our arguments into a string
    my $new_question = join("      ", @_);
    # Get our instance variable
    my @questions = @{$self->{"questions"}};
    # Add the new question
    push (@questions, $new_question);
    # Reset the instance variable
    $self->{"questions"} = \@questions;
    # Return successfully (= 0)
    return 0;
}

这个版本的 addQuestion 非常简单,即使不是很健壮。例如,它不检查以确保正确答案是 A、B、C 或 D 之一。但它确实允许我们将新问题添加到 QuizQuestions 对象。请注意,addQuestion 既检索又设置实例变量 questions 的值。

如果我们有兴趣扩展关于 Emacs 的测验,我们可以按以下方式使用 addQuestion

my $error = $questions->loadFile;
&log_and_die($error) if $error;
$questions->addQuestion(
"What term describes the cursor's current location?",
  "mark", "point", "cursor", "mouse", "B");

在执行此代码后,$questions 包含一个问题。但是,此问题在程序退出时会丢失,因为我们尚未将新问题保存到测验文件。为了将问题保存到测验文件,请像这样定义 saveFile

sub saveFile
{
    # Get ourselves
    my $self = shift;
    # Open the questions file for writing
    open (QUESTIONS, ">$questionDir" .
                    $self->{"quizname"}) ||
        return "Could not open " .
                    $self->{"quizname"} . " for writing";
    # Loop through the questions
    my @questions = @{$self->{"questions"}};
    my $question;
    for each $question (@questions)
    {
        print QUESTIONS $question, "\n";
    }
    close(QUESTIONS);
    return 0;
}
此代码迭代问题,并将它们写入测验文件。由于我们将所有问题都写入磁盘而不是附加它们,因此我们在打开文件时使用 >,从而覆盖以前存在的任何数据。

由于 saveFile 仅保存 questions 实例变量的内容,因此它有效地清除了文件中的注释和空白字符。当然,任何使用程序创建测验文件的人都不太可能查看注释。尽管如此,更完善的 saveFileQuizQuestions 对象可能会让用户向文件中添加注释和空白字符,以及问题。(显然,HTML 表单也必须允许这样做。)

我们的 saveFile 版本使用与 loadFile 相同的系统来报告错误——通过返回一个字符串,而没有错误则通过返回 0 来指示。这使我们可以使用以下代码

$error = $questions->saveFile;
&log_and_die($error) if $error;

现在您已经看到了 create-quizfile.pl 的骨架,您应该对清单 5中显示的程序有一个很好的理解。这个版本的 create-quizfile.pl 非常简单明了。它检查用户是否输入了一个问题;如果问题有文本,它将从提交的 HTML 表单中获取剩余的参数。

现在是记住 CGI 程序将用户定义的字符串写入文件系统可能很危险的好时机,因此必须将它们放置在限制授权用户访问的位置,或者通过使用 HTTP 服务器的内置保护,或者通过将此类程序放置在防火墙后面。无论这看起来多么不可能,用户最终可能会发现您有一个名为 create-quizfile.pl 的程序,并在您的系统上创建测验,可能会覆盖您的创作。

本月,我们通过检查测验文件的完整性并允许用户使用 HTML 表单创建测验文件,使我们的测验引擎对非程序员更加友好。当用户想要编辑测验文件时会发生什么?目前,他们只能在磁盘上修改文件,这再次打开了潜在语法问题的潘多拉魔盒。虽然我们可以使用简单的错误检查代码发现这些问题,但创建一个可以编辑和创建测验文件的程序可能是一个好主意。下个月,我们将修改 create-quizfile.pl 来做到这一点,使我们的测验系统更易于所有人处理。

Reuven M. Lerner 是一位居住在以色列海法的互联网和 Web 顾问,自 1993 年初以来一直使用 Web。在他的业余时间,他做饭、阅读并为社区的教育项目做志愿者。可以通过电子邮件 reuven@netvision.net.il 与他联系。

加载 Disqus 评论