原文:Realtime Web Apps
协议:CC BY-NC-SA 4.0
九、构建后端:第二部分
在上一章中,您构建了一个可工作的 MVC 框架,并将家庭控制器和视图放在一起。在本章中,您将通过构建Question
和Room
组件来完成应用的后端。本章使用了Model
类,以及您在前一章中构建的表单提交处理。
构建问题
在Room
控制器之前构建Question
控制器似乎有些落后,但是因为Room
控制器需要来自Question
控制器的几个标记片段来输出它的视图,所以让我们从这里开始,然后处理Room
控制器。
构建问题控制器
首先,在system/controllers/
中创建一个名为class.question.inc.php
的新文件,并添加以下代码:
<?php /** * Processes output for the Question view * * @author Jason Lengstorf <jason@lengstorf.com> * @author Phil Leggetter <phil@leggetter.co.uk> */ class Question extends Controller { public $room_id, $is_presenter = FALSE; /** * Initializes the class * * @param $options array Options for the controller * @return void */ public function __construct( $options ) { parent::__construct($options); $this->room_id = isset($options[0]) ? (int) $options[0] : 0; if ($this->room_id===0) { throw new Exception("Invalid room ID supplied"); } } /** * Generates the title of the page * * @return string The title of the page */ public function get_title( ) { // Questions can't be called directly, so this is unused return NULL; } /** * Loads and outputs the view's markup * * @return string The HTML markup to display the view */ public function output_view( ) { $questions = $this->get_questions(); $output = NULL; foreach ($questions as $question) { /* * Questions have their own view type, so this section initializes * and sets up variables for the question view */ $view = new View('question'); $view->question = $question->question; $view->room_id = $this->room_id; $view->question_id = $question->question_id; $view->vote_count = $question->vote_count; if ($question->is_answered==1) { $view->answered_class = 'answered'; } else { $view->answered_class = NULL; } // TODO: Check if the user has already voted up the question $view->voted_class = NULL; // TODO: Load the vote up form for attendees, but not presenters $view->vote_link = ''; // TODO: Load the answer form for presenters, but not attendees $view->answer_link = ''; // Returns the output of render() instead of printing it $output .= $view->render(FALSE); } return $output; } }
复制
这个方法还没有完成,但是构建模块已经就位,可以开始查看视图将如何形成。
构造函数触发主Controller
构造函数,然后检查有效的房间 id,如果在 URI 中没有传递,则抛出一个错误。
因为问题永远不会单独显示——意思是在房间的上下文之外——get_title()
方法只返回NULL
。记住它不需要声明,因为它是抽象父类的一部分。
output_view()
方法使用get_questions()
方法加载房间的所有问题,稍后您将对其进行定义。然后它遍历每个问题,加载问题视图并用单个问题的数据填充它。一些变量需要更新;它们中的每一个都被标上了TODO
的注释,所以以后回来写这些内容时,很容易就能发现它们。
添加问题视图
问题应用的视图看起来不太像;这只是你在第七章写的 HTML 的一个片段。然而,它有很多变数。
创建一个名为question.inc.php
的新文件,并将其存储在system/views/
中。在里面,添加以下内容:
<li id="question-<?php echo $question_id; ?>" data-count="<?php echo $vote_count; ?>" class="<?php echo $voted_class, ' ', $answered_class; ?>"> <?php echo $answer_link; ?> <p> <?php echo $question; ?> </p> <?php echo $vote_link; ?> </li><!--/#question-<?php echo $question_id; ?>-->
复制
这个标记显示了在output_view()
中设置的变量,如前所示。目前,这个视图看起来不太像,因为$voted_class
、$voted_link
和$answer_link
都是NULL
或空的。
完成视图
声明的几个变量是NULL
或空的,因为检索所需数据的方法还不存在。根据注释中的TODO
s,循环还需要:
- 检查用户是否已经对问题投了赞成票
- 为与会者而不是演示者加载“向上投票”表单
- 为演示者而不是与会者加载答案表单
检查用户是否已经对某个问题投了赞成票
为了确定用户是否对某个问题投了赞成票,我们将使用一个简单的 cookie。当用户为一个问题投票时,会存储一个名为voted_for_n
(其中 n 是问题的 ID)的 cookie。这将允许应用防止一个用户提交多个投票。
要检查 cookie,将以下粗体代码添加到output_view()
:
public function output_view( ) { $questions = $this->get_questions(); $output = NULL; foreach ($questions as $question) { /* * Questions have their own view type, so this section initializes * and sets up variables for the question view */ $view = new View('question'); $view->question = $question->question; $view->room_id = $this->room_id; $view->question_id = $question->question_id; $view->vote_count = $question->vote_count; if ($question->is_answered==1) { $view->answered_class = 'answered'; } else { $view->answered_class = NULL; } // Checks if the user has already voted up this question $cookie = 'voted_for_' . $question->question_id; if (isset($_COOKIE[$cookie]) && $_COOKIE[$cookie]==1) { $view->voted_class = 'voted'; } else { $view->voted_class = NULL; } // TODO: Load the vote up form for attendees, but not presenters $view->vote_link = ''; // TODO: Load the answer form for presenters, but not attendees $view->answer_link = ''; // Returns the output of render() instead of printing it $output .= $view->render(FALSE); } return $output; }
复制
这段代码检查一个 cookie,这个 cookie 表示这个问题已经被投票了,如果是的话,设置一个类名来改变样式。
警告在生产环境中,应用需要为关闭 cookie 的用户添加故障保护,或者在未启用 cookie 的情况下禁止用户使用应用。完全锁定表单提交超出了本书的范围,但是如果你想了解更多,网上有很多很好的资源。
加载向上投票表单
为了向与会者而不是演示者显示向上投票表单,需要创建一个新方法,该方法将有条件地生成一个视图,该视图可用于问题视图中的输出。
在system/controllers/class.question.inc.php
中,添加以下粗体代码:
public function output_view( ) { $questions = $this->get_questions(); $output = NULL; foreach ($questions as $question) { /* * Questions have their own view type, so this section initializes * and sets up variables for the question view */ $view = new View('question'); $view->question = $question->question; $view->room_id = $this->room_id; $view->question_id = $question->question_id; $view->vote_count = $question->vote_count; if ($question->is_answered==1) { $view->answered_class = 'answered'; } else { $view->answered_class = NULL; } // Checks if the user has already voted up this question $cookie = 'voted_for_' . $question->question_id; if (isset($_COOKIE[$cookie]) && $_COOKIE[$cookie]==1) { $view->voted_class = 'voted'; } else { $view->voted_class = NULL; } $view->vote_link = $this->output_vote_form( $this->room_id, $question->question_id, $question->is_answered ); // TODO: Load the answer form for presenters, but not attendees $view->answer_link = ''; // Returns the output of render() instead of printing it $output .= $view->render(FALSE); } return $output; } /** * Generates the voting form for attendees * * @param $question_id int The ID of the question * @param $answered int 1 if answered, 0 if unanswered * @return mixed Markup if attendee, NULL if presenter */ protected function output_vote_form( $room_id, $question_id, $answered ) { $view = new View('question-vote'); $view->room_id = $room_id; $view->question_id = $question_id; $view->form_action = APP_URI . 'question/vote'; $view->nonce = $this->generate_nonce(); $view->disabled = $answered==1 ? 'disabled' : NULL; return $view->render(FALSE); }
复制
output_vote_form()
方法接受三个参数:当前房间的 ID、当前问题的 ID 以及该问题是否已被回答。
然后它加载一个新的视图—question-vote
—并为输出设置变量。房间 ID、问题 ID、表单动作和 nonce 以及$disabled
都被存储,如果问题已经被标记为已回答,则防止表单被提交。
这个方法返回它的输出,存储在$vote_link
变量中,在这里它成为问题视图的一部分。
添加向上投票表单视图
投票表单的视图非常简单。在system/views/
中创建一个名为question-vote.inc.php
的新文件,并添加以下标记:
<form method="post" class="vote" action="<?php echo $form_action; ?>"> <input value="I also have this question." type="submit"<?php echo $disabled; ?>/> <input type="hidden" name="question_id" value="<?php echo $question_id; ?>" /> <input type="hidden" name="room_id" value="<?php echo $room_id; ?>" /> <input type="hidden" name="nonce" value="<?php echo $nonce; ?>" /> </form>
复制
该标记使用在output_vote_form()
中设置的变量来动态生成按钮,该按钮允许与会者提交对某个问题的投票。
加载答案表单
与 vote up 表单非常相似,您现在需要添加一个方法来加载答案表单,这允许演示者标记一个已回答的问题。将以下代码添加到Question
类:
public function output_view( ) { $questions = $this->get_questions(); $output = NULL; foreach ($questions as $question) { /* * Questions have their own view type, so this section initializes * and sets up variables for the question view */ $view = new View('question'); $view->question = $question->question; $view->room_id = $this->room_id; $view->question_id = $question->question_id; $view->vote_count = $question->vote_count; if ($question->is_answered==1) { $view->answered_class = 'answered'; } else { $view->answered_class = NULL; } // Checks if the user has already voted up this question $cookie = 'voted_for_' . $question->question_id; if (isset($_COOKIE[$cookie]) && $_COOKIE[$cookie]==1) { $view->voted_class = 'voted'; } else { $view->voted_class = NULL; } $view->vote_link = $this->output_vote_form( $this->room_id, $question->question_id, $question->is_answered ); $view->answer_link = $this->output_answer_form( $this->room_id, $question->question_id ); // Returns the output of render() instead of printing it $output .= $view->render(FALSE); } return $output; } protected function output_vote_form( $room_id, $question_id, $answered ) { $view = new View('question-vote'); $view->room_id = $room_id; $view->question_id = $question_id; $view->form_action = APP_URI . 'question/vote'; $view->nonce = $this->generate_nonce(); $view->disabled = $answered==1 ? 'disabled' : NULL; return $view->render(FALSE); } /** * Generates the answering form for presenter * * @param $room_id int The ID of the room * @param $question_id int The ID of the question * @return mixed Markup if presenter, NULL if attendee */ protected function output_answer_form( $room_id, $question_id ) { $view = new View('question-answer'); $view->room_id = $room_id; $view->question_id = $question_id; $view->form_action = APP_URI . 'question/answer'; $view->nonce = $this->generate_nonce(); return $view->render(FALSE); }
复制
该方法遵循与 output_vote_form()
相同的模式:它使用 question-answer
创建一个新视图,并设置用于生成标记的变量。
添加答案表单视图
创建一个名为question-answer.inc.php
的新文件,并将其保存在system/views/
中,其中包含以下标记:
<form method="post" class="answer" action="<?php echo $form_action; ?>"> <input type="submit" value="Answer this question." /> <input type="hidden" name="question_id" value="<?php echo $question_id; ?>" /> <input type="hidden" name="room_id" value="<?php echo $room_id; ?>" /> <input type="hidden" name="nonce" value="<?php echo $nonce; ?>" /> </form>
复制
该标记使用在output_answer_form()
中设置的变量来生成标记,供演示者将问题标记为已回答。
为投票和回答的问题添加样式
因为已经被投票或回答的问题不再是交互式的,按钮应该不再显示为可点击的。打开assets/styles/main.css
并在媒体查询上方插入以下 CSS:
/* Voted and answered styles ----------------------------------------------------------------------------*/ #questions .voted .vote input { background-position: left bottom; width: 78px; cursor: initial; } #questions .answered .answer input:active, #questions .answered .answer input:hover, #questions .answered .answer input:focus { background-position: right top; cursor: initial; } #questions .answered .vote input:active, #questions .answered .vote input:hover, #questions .answered .vote input:focus { background-position: left bottom; cursor: initial; } /* Transition effects ----------------------------------------------------------------------------*/ #questions li,#questions .vote { -webkit-transition: opacity 1s ease-in-out; -moz-transition: opacity 1s ease-in-out; -ms-transition: opacity 1s ease-in-out; -o-transition: opacity 1s ease-in-out; transition: opacity 1s ease-in-out; } #questions.closed,#questions li.answered { opacity: .4; } #questions.closed .vote,#questions .answered .vote { opacity: .2; }
复制
这些样式防止按钮在悬停时高亮显示,并防止鼠标光标变成指针,这是元素可点击的标准指示。
当投票或回答问题时,过渡效果可以创建动画淡入淡出效果。在这种情况下,为了触发 CSS 转换,元素需要添加一个类,所以请记住,在下一章实现 realtime 和 jQuery 效果之前,转换是不可见的。
加载房间的所有问题
output_view()
方法的最后一部分是目前不存在的方法get_questions()
。实际的数据库查询将在本章稍后添加到问题模型中,但是现在,让我们在控制器中声明方法。
将以下粗体代码添加到Question
类中:
public function output_ask_form( $is_active, $email ) { if ($is_active) { $view = new View('ask-form'); $view->room_id = $this->room_id; $view->form_action = APP_URI . 'question/ask'; $view->nonce = $this->generate_nonce(); return $view->render(FALSE); } else { $view = new View('room-closed'); $view->email = $email; return $view->render(FALSE); } } /** * Loads questions for the room * * @return array The question data as an array of objects */ protected function get_questions( ) { return $this->model->get_room_questions($this->room_id); }
复制
这个方法只是从 Question_Model
类中调用一个方法,这个类还没有被定义。一旦建立了模型,这个方法将返回给定房间 ID 的所有问题。
添加提问表单
除了投票和回答表单之外,还有一个表单需要添加到Question
类中:用于提出新问题的表单。
添加提问方法
在Question
类中,使用下面的粗体代码添加新方法:
protected function output_answer_form( $room_id, $question_id ) { $view = new View('question-answer'); $view->room_id = $room_id; $view->question_id = $question_id; $view->form_action = APP_URI . 'question/answer'; $view->nonce = $this->generate_nonce(); return $view->render(FALSE); } /** * Generates the form to ask a new question * * @param $is_active bool Whether or not the room is active * @param $email string The email address of the presenter * @return string The markup to display the form */ public function output_ask_form( $is_active, $email ) { if ($is_active) { $view = new View('ask-form'); $view->room_id = $this->room_id; $view->form_action = APP_URI . 'question/ask'; $view->nonce = $this->generate_nonce(); return $view->render(FALSE); } else { $view = new View('room-closed'); $view->email = $email; return $view->render(FALSE); } }
复制
这个方法与其他两个 form 方法非常相似,但是有一个重要的区别:根据房间是否活动,这个方法可以返回两个视图。
ask-form
视图输出允许与会者提出新问题的表单。
room-closed
视图使用演示者的电子邮件地址,允许任何进入封闭房间的人跟进任何其他问题。
添加提问视图
在system/views/
中创建一个名为ask-form.inc.php
的新文件,并插入以下标记:
<form id="ask-a-question" method="post" action="<?php echo $form_action; ?>"> <label> If you have a question and you don't see it below, ask it here. <input type="text" name="new-question" tabindex="1" /> </label> <input type="submit" value="Ask" tabindex="2" /> <input type="hidden" name="room_id" value="<?php echo $room_id; ?>" /> <input type="hidden" name="nonce" value="<?php echo $nonce; ?>" /> </form><!--/#ask-a-question-->
复制
这种标记创建了询问新问题的形式。
添加房间封闭视图
在system/views/
中,用以下标记添加一个名为room-closed.inc.php
的新文件:
<h3>This session has ended.</h3> <p> If you have a question that wasn't answered, please <a href="mailto:<?php echo $email; ?>">email the presenter</a>. </p>
复制
这种标记让与会者知道房间关闭了,但提供了一个电子邮件地址,以便与演示者联系,这样他就不会完全不走运。
构建问题模型
为了存储关于问题及其投票的数据,您现在需要创建一个模型类,它将包含所有与问题相关的数据库操作方法。
首先在system/models/
中创建一个名为class.question_model.inc.php
的新文件,其类定义如下:
<?php /** * Creates database interaction methods for questions * * @author Jason Lengstorf <jason@lengstorf.com> * @author Phil Leggetter <phil@leggetter.co.uk> */ class Question_Model extends Model { }
复制
加载房间的所有问题
为了加载一个房间的所有问题,房间 ID 被传递给get_room_questions()
方法。结果作为对象加载,然后传递回控制器进行处理。
为了按逻辑顺序检索问题(即,最高票数、未回答的问题显示在列表顶部),使用LEFT JOIN
来利用来自question_votes
的投票计数进行排序。
将以下粗体代码添加到Question_Model
:
class Question_Model extends Model { /** * Loads all questions for a given room * * @param $room_id int The ID of the room * @return array The questions attached to the room */ public function get_room_questions( $room_id ) { $sql = "SELECT id AS question_id, room_id, question, is_answered, vote_count FROM questions LEFT JOIN question_votes ON( questions.id = question_votes.question_id ) WHERE room_id = :room_id ORDER BY is_answered, vote_count DESC"; $stmt = self::$db->prepare($sql); $stmt->bindParam(':room_id', $room_id, PDO::PARAM_INT); $stmt->execute(); $questions = $stmt->fetchAll(PDO::FETCH_OBJ); $stmt->closeCursor(); return $questions; } }
复制
保存新问题
为了将新问题保存到数据库,房间 ID 和新问题文本(作为字符串)都被传递给create_question()
方法。第一个查询将问题插入到questions
表中,然后将新保存的问题的 ID 存储在$question_id
中。
接下来,使用新创建的问题 ID 将问题的第一票(因为提问的用户算作第一票)添加到question_votes
表中。
通过将以下粗体代码添加到Question_Model
来实现该方法:
public function get_room_questions( $room_id ) { $sql = "SELECT id AS question_id, room_id, question, is_answered, vote_count FROM questions LEFT JOIN question_votes ON( questions.id = question_votes.question_id ) WHERE room_id = :room_id ORDER BY is_answered, vote_count DESC"; $stmt = self::$db->prepare($sql); $stmt->bindParam(':room_id', $room_id, PDO::PARAM_INT); $stmt->execute(); $questions = $stmt->fetchAll(PDO::FETCH_OBJ); $stmt->closeCursor(); return $questions; } /** * Stores a new question with all the proper associations * * @param $room_id int The ID of the room * @param $question string The question text * @return array The IDs of the room and the question */ public function create_question( $room_id, $question ) { // Stores the new question in the database $sql = "INSERT INTO questions (room_id, question) VALUES (:room_id, :question)"; $stmt = self::$db->prepare($sql); $stmt->bindParam(':room_id', $room_id); $stmt->bindParam(':question', $question); $stmt->execute(); $stmt->closeCursor(); // Stores the ID of the new question $question_id = self::$db->lastInsertId(); /* * Because creating a question counts as its first vote, this adds a * vote for the question to the database */ $sql = "INSERT INTO question_votes VALUES (:question_id, 1)"; $stmt = self::$db->prepare($sql); $stmt->bindParam(":question_id", $question_id, PDO::PARAM_INT); $stmt->execute(); $stmt->closeCursor(); return array( 'room_id' => $room_id, 'question_id' => $question_id, ); }
复制
向问题添加投票
更新投票计数很简单:对于给定的问题 ID,vote_question()
方法将投票计数增加1
。将这个方法(粗体)添加到Question_Model
类:
return array( 'room_id' => $room_id, 'question_id' => $question_id, ); } /** * Increases the vote count of a given question * * @param $room_id int The ID of the room * @param $question_id int The ID of the question * @return array The IDs of the room and the question */ public function vote_question( $room_id, $question_id ) { // Increments the vote count for the question $sql = "UPDATE question_votes SET vote_count = vote_count+1 WHERE question_id = :question_id"; $stmt = self::$db->prepare($sql); $stmt->bindParam(':question_id', $question_id, PDO::PARAM_INT); $stmt->execute(); $stmt->closeCursor(); return array( 'room_id' => $room_id, 'question_id' => $question_id, ); } }
复制
数据库查询通过在当前值vote_count = vote_count+1
上加 1,将具有给定 ID 的问题的投票计数增加 1,然后返回房间和问题 ID。
将问题标记为已回答
最后,为了将问题标记为已回答,具有给定 ID 的问题的is_answered
列被更新为1
。将以下粗体代码添加到Question_Model
:
$stmt->closeCursor(); return array( 'room_id' => $room_id, 'question_id' => $question_id, ); } /** * Marks a given question as answered * * @param $room_id int The ID of the room * @param $question_id int The ID of the question * @return array The IDs of the room and question */ public function answer_question( $room_id, $question_id ) { $sql = "UPDATE questions SET is_answered = 1 WHERE id = :question_id"; $stmt = self::$db->prepare($sql); $stmt->bindParam(':question_id', $question_id, PDO::PARAM_INT); $stmt->execute(); $stmt->closeCursor(); return array( 'room_id' => $room_id, 'question_id' => $question_id, ); } }
复制
向控制器添加表单处理程序和数据访问方法
应用问题部分的最后一点是将动作数组、模型和动作处理程序类添加到Question
控制器中。
首先用模型声明和动作数组更新构造函数。将以下内容添加到system/controllers/class.question.inc.php
:
public function __construct( $options ) { parent::__construct($options); $this->model = new Question_Model; // Checks for a form submission $this->actions = array( 'ask' => 'create_question', 'vote' => 'vote_question', 'answer' => 'answer_question', ); if (array_key_exists($options[0], $this->actions)) { $this->handle_form_submission($options[0]); exit; } else { $this->room_id = isset($options[0]) ? (int) $options[0] : 0; if ($this->room_id===0) { throw new Exception("Invalid room ID supplied"); } } }
复制
这将加载用于数据访问的Question_Model
类,然后声明三种可能的表单动作及其所需的动作处理程序方法。
保存新问题
保存新问题的操作处理程序相当复杂,因为它是在下一章构建的——在下一章中,我们将开始添加实时功能——考虑在内。因此,它不仅存储新的问题,还生成新的问题视图以供返回,这样以后就不需要在客户端呈现标记了。它还为用户添加了一个 cookie,表明他们已经为这个问题投了票。
将下面的粗体代码添加到Question
类中:
protected function get_questions( ) { return $this->model->get_room_questions($this->room_id); } /** * Adds a new question to the database * * @return array Information about the updated question */ protected function create_question( ) { $room_id = $this->sanitize($_POST['room_id']); $question = $this->sanitize($_POST['new-question']); $output = $this->model->create_question($room_id, $question); // Make sure valid output was returned if (is_array($output) && isset($output['question_id'])) { $room_id = $output['room_id']; $question_id = $output['question_id']; // Generates markup for the question (for realtime addition) $view = new View('question'); $view->question = $question; $view->room_id = $room_id; $view->question_id = $question_id; $view->vote_count = 1; $view->answered_class = NULL; $view->voted_class = NULL; $view->vote_link = $this->output_vote_form( $room_id, $question_id, FALSE ); $view->answer_link = $this->output_answer_form( $room_id, $question_id ); $output['markup'] = $view->render(FALSE); } else { throw new Exception('Error creating the room.'); } // Stores a cookie so the attendee can only vote once setcookie('voted_for_' . $question_id, 1, time() + 2592000, '/'); return $output; } }
复制
该方法首先净化提交的数据,使用模型的create_question()
方法将其存储在数据库中,并检查有效的返回值。然后,它创建一个新的question
视图,并存储所有变量,为一个新问题生成标记。存储了一个 cookie,表明与会者发布了问题的第一个向上投票;然后返回标记。
向问题添加投票
向一个问题添加新的投票会执行vote_question()
方法,将新的投票存储在数据库中,然后为投票者设置一个 cookie,以防止对同一个问题进行多次投票。将以下粗体代码添加到Question
控制器中:
// Stores a cookie so the attendee can only vote once setcookie('voted_for_' . $question_id, 1, time() + 2592000, '/'); return $output; } /** * Increments the vote count for a given question * * @return array Information about the updated question */ protected function vote_question( ) { $room_id = $this->sanitize($_POST['room_id']); $question_id = $this->sanitize($_POST['question_id']); // Makes sure the attendee hasn't already voted for this question $cookie_id = 'voted_for_' . $question_id; if (!isset($_COOKIE[$cookie_id]) || $_COOKIE[$cookie_id]!=1) { $output = $this->model->vote_question($room_id, $question_id); // Sets a cookie to make it harder to post multiple votes setcookie($cookie_id, 1, time() + 2592000, '/'); } else { $output = array('room_id'=>$room_id); } return $output; } }
复制
将问题标记为已回答
为了将问题标记为已回答,提交表单的用户必须是演示者。这和投票一样,是基于 cookie 的。该方法在执行answer_question()
方法之前检查演示者的 cookie。
将以下粗体显示的代码添加到Question
控制器中:
// Stores a cookie so the attendee can only vote once setcookie('voted_for_' . $question_id, 1, time() + 2592000, '/'); return $output; } /** * Marks a given question as answered * * @return array Information about the updated question */ protected function answer_question( ) { $room_id = $this->sanitize($_POST['room_id']); $question_id = $this->sanitize($_POST['question_id']); // Makes sure the person answering the question is the presenter $cookie_id = 'presenter_room_' . $room_id; if (isset($_COOKIE[$cookie_id]) && $_COOKIE[$cookie_id]==1) { return $this->model->answer_question($room_id, $question_id); } return array('room_id'=>$room_id); } }
复制
该方法清理提交的表单值,然后检查演示者的 cookie,以验证当前用户是否有权将问题标记为已回答。如果 cookie 有效,模型的answer_question()
方法被触发,其返回的数据被传递;无效或缺失的 cookie 只会将用户返回到房间,而不会通过返回房间 id 进行任何处理。
建造房间
这个应用的最后一部分是为房间添加控制器、模型和视图。这与问题的功能非常相似,除了它实际上加载了Question
控制器来加载那些视图并利用它的方法。
添加房间控制器
第一步是创建Room
控制器。在system/controllers/
中,添加一个名为class.room.inc.php
的新文件,并从以下代码开始:
<?php /** * Processes output for the Room view * * @author Jason Lengstorf <jason@lengstorf.com> * @author Phil Leggetter <phil@leggetter.co.uk> */ class Room extends Controller { public $room_id, $is_presenter, $is_active; /** * Initializes the view * * @param $options array Options for the view * @return void */ public function __construct( $options ) { parent::__construct($options); $this->model = new Room_Model; $this->room_id = isset($options[0]) ? (int) $options[0] : 0; if ($this->room_id===0) { throw new Exception("Invalid room ID supplied"); } $this->room = $this->model->get_room_data($this->room_id); $this->is_presenter = $this->is_presenter(); $this->is_active = (boolean) $this->room->is_active; } /** * Generates the title of the page * * @return string The title of the page */ public function get_title( ) { return $this->room->room_name . ' by ' . $this->room->presenter_name; } /** * Loads and outputs the view's markup * * @return void */ public function output_view( ) { $view = new View('room'); $view->room_id = $this->room->room_id; $view->room_name = $this->room->room_name; $view->presenter = $this->room->presenter_name; $view->email = $this->room->email; if (!$this->is_presenter) { $view->ask_form = $this->output_ask_form(); $view->questions_class = NULL; } else { $view->ask_form = NULL; $view->questions_class = 'presenter'; } if (!$this->is_active) { $view->questions_class = 'closed'; } $view->controls = $this->output_presenter_controls(); $view->questions = $this->output_questions(); $view->render(); } }
复制
除了标准的构造函数——调用父构造函数,设置模型,并确保提供了有效的选项—Room
构造函数还设置了一些特定于房间的属性。
$room
属性将保存房间的基本信息,这些信息作为对象从get_room_data()
方法返回。该模型将在本章后面的内容中进行设置以检索这些数据。
为了处理演示者和与会者在房间标记上的差异,$is_presenter
保存了一个由is_presenter()
方法确定的布尔值,稍后将编写这个方法。
最后,$is_active
属性作为房间活动状态的快捷方式。因为它在数据库中存储为1
或0
,所以它被转换为布尔值,以允许在控制器的方法中进行严格的布尔比较。
get_title()
方法使用房间和演示者的名字为房间生成一个有意义的标题。
在output_view()
中,加载了房间视图,并设置了一些变量,这将在本章的稍后部分进行介绍。
确定用户是否是演示者
因为向演示者显示的标记不同于向与会者显示的标记,所以应用需要一种方法来确定用户是否是当前房间的演示者。这将被存储为一个 cookie。
将以下粗体代码添加到Room
控制器中:
$view->controls = $this->output_presenter_controls(); $view->questions = $this->output_questions(); $view->render(); } /** * Determines whether or not the current user is the presenter * * @return boolean TRUE if it's the presenter, otherwise FALSE */ protected function is_presenter( ) { $cookie = 'presenter_room_' . $this->room->room_id; return (isset($_COOKIE[$cookie]) && $_COOKIE[$cookie]==1); } }
复制
该方法使用当前房间的 ID 计算出 cookie 名称,然后如果 cookie 设置为等于1
,则返回TRUE
。
添加房间视图
使用第七章的中的房间标记,通过将以下代码添加到位于system/views/room.inc.php
: 的新文件中来创建房间视图
<section> <header> <h2><?php echo $room_name; ?></h2> <p> Presented by<?php echo $presenter; ?> (<a href="mailto:<?php echo $email; ?>">email</a>) </p> <?php echo $controls; ?> </header> <?php echo $ask_form; ?> <ul id="questions" class="<?php echo $questions_class; ?>"> <?php echo $questions; ?> </ul><!--/#questions--> </section>
复制
该标记生成向与会者和演示者显示房间及其问题所需的一切。
显示 Ask 表单
ask 表单需要一个新视图,该视图将由Room
类中的一个新方法加载。如果您还记得,ask 表单的生成已经由Question
控制器处理了,所以新方法将简单地调用Question
控制器上的output_ask_form()
方法。
将以下粗体代码添加到Room
控制器中:
$view->controls = $this->output_presenter_controls(); $view->questions = $this->output_questions(); $view->render(); } /** * Shows the "ask a question" form or a notice that the room has ended * * @param $email string The presenter's email address * @return string Markup for the form or notice */ protected function output_ask_form( ) { $controller = new Question(array($this->room_id)); return $controller->output_ask_form( $this->is_active, $this->room->email ); } /** * Determines whether or not the current user is the presenter * * @return boolean TRUE if it's the presenter, otherwise FALSE */ protected function is_presenter( ) {
复制
显示演讲者控制
对于房间的展示者,我们需要提供房间的直接链接和关闭房间的选项(如果房间已经关闭,则重新打开房间)。为此,向Room
控制器添加一个新方法,检查用户是否是演示者;然后检查该房间是否是活动的。
对于非活动房间,重新打开控件将被加载并返回,以便在主房间视图中使用。
活动房间加载标准控件并返回它们。
将以下粗体代码添加到class.room.inc.php
:
return $controller->output_ask_form( $this->is_active, $this->room->email ); } /** * Shows the presenter his controls (or nothing, if not the presenter) * * @return mixed Markup for the controls (or NULL) */ protected function output_presenter_controls( ) { if ($this->is_presenter) { if (!$this->is_active) { $view_class = 'presenter-reopen'; $form_action = APP_URI . 'room/open'; } else { $view_class = 'presenter-controls'; $form_action = APP_URI . 'room/close'; } $view = new View($view_class); $view->room_id = $this->room->room_id; $view->room_uri = APP_URI . 'room/' . $this->room_id; $view->form_action = $form_action; $view->nonce = $this->generate_nonce(); return $view->render(FALSE); } return NULL; } /** * Determines whether or not the current user is the presenter * * @return boolean TRUE if it's the presenter, otherwise FALSE */ protected function is_presenter( ) {
复制
添加不活跃的会议室演示者控制视图
非活动房间的视图是一个简单的表单,输入内容为“打开该房间”。
使用以下标记在system/views/
中创建一个名为presenter-reopen.inc.php
的文件:
<form id="close-this-room" method="post" action="<?php echo $form_action; ?>"> <input type="submit" value="Open This Room" /> <input type="hidden" name="room_id" value="<?php echo $room_id; ?>" /> <input type="hidden" name="nonce" value="<?php echo $nonce; ?>" /> </form><!--/#close-this-room-->
复制
添加活跃的会议室演示者控制视图
对于活动房间,显示带有房间 URI 的禁用输入,以及显示“关闭此房间”的按钮。
将以下标记添加到system/views/
中名为presenter-controls.inc.php
的新文件中:
<form id="close-this-room" method="post" action="<?php echo $form_action; ?>"> <label> Link to your room. <input type="text" name="room-uri" value="<?php echo $room_uri; ?>" disabled /> </label> <input type="submit" value="Close This Room" /> <input type="hidden" name="room_id" value="<?php echo $room_id; ?>" /> <input type="hidden" name="nonce" value="<?php echo $nonce; ?>" /> </form><!--/#close-this-room-->
复制
展示问题
为了显示房间的问题,output_questions()
方法将利用Question
控制器遍历现有的问题并返回标记。
在生成标记之前,它设置了$is_presenter
属性,以便为用户类型返回正确的标记。
将以下粗体代码添加到Room
控制器中:
return $view->render(FALSE); } return NULL; } /** * Loads and formats the questions for this room * * @return string The marked up questions */ protected function output_questions( ) { $controller = new Question(array($this->room_id)); // Allows for different output for presenters vs. attendees $controller->is_presenter = $this->is_presenter; return $controller->output_view(); } /** * Determines whether or not the current user is the presenter * * @return boolean TRUE if it's the presenter, otherwise FALSE */ protected function is_presenter( ) {
复制
建立房间模型
因为应用需要存储和操作房间数据,所以您需要为房间创建一个模型,名为Room_Model
。这将存储在名为class.room_model.inc.php
的文件的system/models/
子目录中。
创建文件,并以这个基本的类定义开始:
<?php /** * Creates database interaction methods for rooms * * @author Jason Lengstorf <jason@lengstorf.com> * @author Phil Leggetter <phil@leggetter.co.uk> */ class Room_Model extends Model { }
复制
创建房间
模型的第一种方法是将新房间保存到数据库中。
这是一个多步骤的过程;该方法需要做以下工作:
- 在
rooms
表格中创建新房间 - 检索新房间的 ID
- 将演示者添加到
presenters
表中(或者在电子邮件重复的情况下更新演示者的显示名称) - 从
presenters
表中检索演示者的 ID - 将房间 ID 映射到
room_owners
表中演示者的 ID。
将以下粗体代码添加到房间模型中:
class Room_Model extends Model { /** * Saves a new room to the database * * @param $presenter string The name of the presenter * @param $email string The presenter's email address * @param $name string The name of the room * @return array An array of data about the room */ public function create_room( $presenter, $email, $name ) { // Creates a new room $sql = 'INSERT INTO rooms (name) VALUES (:name)'; $stmt = self::$db->prepare($sql); $stmt->bindParam(':name', $name, PDO::PARAM_STR, 255); $stmt->execute(); $stmt->closeCursor(); // Gets the generated room ID $room_id = self::$db->lastInsertId(); // Creates (or updates) the presenter $sql = "INSERT INTO presenters (name, email) VALUES (:name, :email) ON DUPLICATE KEY UPDATE name=:name"; $stmt = self::$db->prepare($sql); $stmt->bindParam(':name', $presenter, PDO::PARAM_STR, 255); $stmt->bindParam(':email', $email, PDO::PARAM_STR, 255); $stmt->execute(); $stmt->closeCursor(); // Gets the generated presenter ID $sql = "SELECT id FROM presenters WHERE email=:email"; $stmt = self::$db->prepare($sql); $stmt->bindParam(':email', $email, PDO::PARAM_STR, 255); $stmt->execute(); $pres_id = $stmt->fetch(PDO::FETCH_OBJ)->id; $stmt->closeCursor(); // Stores the room:presenter relationship $sql = 'INSERT INTO room_owners (room_id, presenter_id) VALUES (:room_id, :pres_id)'; $stmt = self::$db->prepare($sql); $stmt->bindParam(":room_id", $room_id, PDO::PARAM_INT); $stmt->bindParam(":pres_id", $pres_id, PDO::PARAM_INT); $stmt->execute(); $stmt->closeCursor(); return array( 'room_id' => $room_id, ); } }
复制
检查房间是否存在
作为加入房间过程的一部分,Room
控制器需要能够验证房间的存在。这个方法简单地选择与给定 ID 匹配的rooms
表中的COUNT()
个房间;计数1
表示房间存在,0
表示不存在。
将以下粗体代码添加到Room_Model
:
// Stores the room:presenter relationship $sql = 'INSERT INTO room_owners (room_id, presenter_id) VALUES (:room_id, :pres_id)'; $stmt = self::$db->prepare($sql); $stmt->bindParam(":room_id", $room_id, PDO::PARAM_INT); $stmt->bindParam(":pres_id", $pres_id, PDO::PARAM_INT); $stmt->execute(); $stmt->closeCursor(); return array( 'room_id' => $room_id, ); } /** * Checks if a given room exists * * @param $room_id int The ID of the room being checked * @return bool Whether or not the room exists */ public function room_exists( $room_id ) { // Loads the number of rooms matching the provided room ID $sql = "SELECT COUNT(id) AS the_count FROM rooms WHERE id = :room_id"; $stmt = self::$db->prepare($sql); $stmt->bindParam(':room_id', $room_id, PDO::PARAM_INT); $stmt->execute(); $room_exists = (bool) $stmt->fetch(PDO::FETCH_OBJ)->the_count; $stmt->closeCursor(); return $room_exists; } }
复制
使用(bool)
将计数显式转换为布尔值意味着该方法将总是返回TRUE
或FALSE
,这对于唯一目的是检查某个东西是否存在的方法很有帮助。
开房
对于一个已经关闭的房间,重新打开它非常简单,只需将具有给定 ID 的房间的rooms
表中的is_active
列设置为1
。
将粗体显示的代码添加到class.room_model.inc.php
:
$room_exists = (bool) $stmt->fetch(PDO::FETCH_OBJ)->the_count; $stmt->closeCursor(); return $room_exists; } /** * Sets a given room's status to "open" * * @param $room_id int The ID of the room being checked * @return array An array of data about the room */ public function open_room( $room_id ) { $sql = "UPDATE rooms SET is_active=1 WHERE id = :room_id"; $stmt = self::$db->prepare($sql); $stmt->bindParam(':room_id', $room_id, PDO::PARAM_INT); $stmt->execute(); $stmt->closeCursor(); return array( 'room_id' => $room_id, ); } }
复制
关闭房间
关闭一个房间与打开一个房间的过程相同,只是在rooms
表中is_active
列被设置为0
。将以下粗体代码插入Room_Model
:
public function open_room( $room_id ) { $sql = "UPDATE rooms SET is_active=1 WHERE id = :room_id"; $stmt = self::$db->prepare($sql); $stmt->bindParam(':room_id', $room_id, PDO::PARAM_INT); $stmt->execute(); $stmt->closeCursor(); return array( 'room_id' => $room_id, ); } /** * Sets a given room's status to "closed" * * @param $room_id int The ID of the room being checked * @return array An array of data about the room */ public function close_room( $room_id ) { $sql = "UPDATE rooms SET is_active=0 WHERE id = :room_id"; $stmt = self::$db->prepare($sql); $stmt->bindParam(':room_id', $room_id, PDO::PARAM_INT); $stmt->execute(); $stmt->closeCursor(); return array( 'room_id' => $room_id, ); } }
复制
装货间信息
加载房间信息可以说是应用中最复杂的查询。它要求如下:
- 将
room_owners
工作台连接到rooms
工作台;然后将presenters
表连接到该表,形成一个完整的数据集 - 从
rooms
表中加载id
、name
和is_active
列 - 将
id
更名为room_id
,将name
更名为room_name
- 从演示者表格中加载
id
、name
和email
列 - 将
id
更名为presenter_id
,将name
更名为presenter_name
使用添加到class.room_model.inc.php
中的以下粗体代码所示的查询完成上述步骤:
public function close_room( $room_id ) { $sql = "UPDATE rooms SET is_active=0 WHERE id = :room_id"; $stmt = self::$db->prepare($sql); $stmt->bindParam(':room_id', $room_id, PDO::PARAM_INT); $stmt->execute(); $stmt->closeCursor(); return array( 'room_id' => $room_id, ); } /** * Retrieves details about a given room * * @param $room_id int The ID of the room being checked * @return array An array of data about the room */ public function get_room_data( $room_id ) { $sql = "SELECT rooms.id AS room_id, presenters.id AS presenter_id, rooms.name AS room_name, presenters.name AS presenter_name, email, is_active FROM rooms LEFT JOIN room_owners ON( rooms.id = room_owners.room_id ) LEFT JOIN presenters ON( room_owners.presenter_id = presenters.id ) WHERE rooms.id = :room_id LIMIT 1"; $stmt = self::$db->prepare($sql); $stmt->bindParam(':room_id', $room_id, PDO::PARAM_INT); $stmt->execute(); $room_data = $stmt->fetch(PDO::FETCH_OBJ); $stmt->closeCursor(); return $room_data; } }
复制
向房间控制器添加表单处理程序
应用后端的最后一步是将表单动作和动作处理程序添加到Room
控制器中。
将表单动作添加到房间控制器
Room
控制器支持四种动作:
- 加入房间
- 创建房间
- 开房
- 关闭房间
将以下粗体代码添加到Room
类的构造函数中,以添加对这四个动作的支持:
public function __construct( $options ) { parent::__construct($options); $this->model = new Room_Model; // Checks for a form submission $this->actions = array( 'join' => 'join_room', 'create' => 'create_room', 'open' => 'open_room', 'close' => 'close_room', ); if (array_key_exists($options[0], $this->actions)) { $this->handle_form_submission($options[0]); exit; } else { $this->room_id = isset($options[0]) ? (int) $options[0] : 0; if ($this->room_id===0) { throw new Exception("Invalid room ID supplied"); } } $this->room = $this->model->get_room_data($this->room_id); $this->is_presenter = $this->is_presenter(); $this->is_active = (boolean) $this->room->is_active; }
复制
该数组为应该调用的方法创建一个操作映射。接下来,if...else
语句检查一个有效的动作,如果传递了一个动作,它就调用handle_form_submission()
来处理它。
加入房间
当用户试图加入一个房间时,控制器需要首先使用来自Room_Model
的room_exists()
方法检查它是否存在。如果是,用户应该被重定向到所请求的房间;否则,他们应该会收到一条错误消息。
将以下粗体代码添加到Room
控制器中:
protected function output_questions( ) { $controller = new Question(array($this->room_id)); // Allows for different output for presenters vs. attendees $controller->is_presenter = $this->is_presenter; return $controller->output_view(); } /** * Checks if a room exists and redirects the user appropriately * * @return void */ protected function join_room( ) { $room_id = $this->sanitize($_POST['room_id']); // If the room exists, creates the URL; otherwise, sends to a 404 if ($this->model->room_exists($room_id)) { $header = APP_URI . 'room/' . $room_id; } else { $header = APP_URI . 'no-room'; } header("Location: " . $header); exit; } /** * Determines whether or not the current user is the presenter * * @return boolean TRUE if it's the presenter, otherwise FALSE */ protected function is_presenter( ) {
复制
创建新房间
要创建一个新房间,控制器需要整理演示者的姓名、电子邮件和房间名称;使用房间模型的create_room()
方法存储它们;然后检查房间是否创建成功。它还应该设置一个 cookie,将房间的创建者标识为演示者。
在class.room.inc.php
中,添加以下粗体代码:
if ($this->model->room_exists($room_id)) { $header = APP_URI . 'room/' . $room_id; } else { $header = APP_URI . 'no-room'; } header("Location: " . $header); exit; } /** * Creates a new room and sets the creator as the presenter * * @return array Information about the updated room */ protected function create_room( ) { $presenter = $this->sanitize($_POST['presenter-name']); $email = $this->sanitize($_POST['presenter-email']); $name = $this->sanitize($_POST['session-name']); // Store the new room and its various associations in the database $output = $this->model->create_room($presenter, $email, $name); // Make sure valid output was returned if (is_array($output) && isset($output['room_id'])) { $room_id = $output['room_id']; } else { throw new Exception('Error creating the room.'); } // Makes the creator of this room its presenter setcookie('presenter_room_' . $room_id, 1, time() + 2592000, '/'); return $output; } /** * Determines whether or not the current user is the presenter * * @return boolean TRUE if it's the presenter, otherwise FALSE */ protected function is_presenter( ) {
复制
重新打开一个封闭的房间
为了重新打开一个已经关闭的房间,清理后的房间 ID 被传递给模型的open_room()
方法。
// Makes the creator of this room its presenter setcookie('presenter_room_' . $room_id, 1, time() + 2592000, '/'); return $output; } /** * Marks a given room as active * * @return array Information about the updated room */ protected function open_room( ) { $room_id = $this->sanitize($_POST['room_id']); return $this->model->open_room($room_id); } /** * Determines whether or not the current user is the presenter * * @return boolean TRUE if it's the presenter, otherwise FALSE */ protected function is_presenter( ) {
复制
关闭房间
关闭一个房间和打开一个房间几乎是一样的,只是调用了close_room()
方法。
protected function open_room( ) { $room_id = $this->sanitize($_POST['room_id']); return $this->model->open_room($room_id); } /** * Marks a given room as closed * * @return array Information about the updated room */ protected function close_room( ) { $room_id = $this->sanitize($_POST['room_id']); return $this->model->close_room($room_id); } /** * Determines whether or not the current user is the presenter * * @return boolean TRUE if it's the presenter, otherwise FALSE */ protected function is_presenter( ) {
复制
全部测试完毕
此时,您的应用代码已经完成,可以进行测试了。让我们运行每一个可用的行动,以验证一切都在按计划进行。
创建您的第一个房间
首先,在您选择的浏览器中加载应用,并输入新房间的详细信息(参见图 9-1 )。
图 9-1。在应用的主页上创建新房间
点击创建你的房间,你将被带到新的空房间(见图 9-2 )。
图 9-2。新创建的房间
注意在一些服务器配置中,运行
PDO::__construct()
时可能会出现 MySQL 错误。这通常意味着您需要将您的php.ini
文件指向mysql.sock
的正确位置。在https://gist.github.com/jlengstorf/5184301
有一个简单的演练,如果您在 Google 上搜索错误消息,还有几个可用的演练。
关闭房间
关闭房间,测试演示者控制是否正常工作。点击关闭此房间按钮,房间变为非活动状态(参见图 9-3 )。
图 9-3。封闭的房间
重新开放房间
确保房间可以通过点击打开此房间按钮重新打开,这将使房间回到其最初的活动状态(参见图 9-4 )。
图 9-4。重新开放的房间
加入房间
接下来,打开不同的浏览器(这意味着完全不同的应用:Firefox、Safari、Opera 或 Internet Explorer,如果你开始使用 Google Chrome 的话)并导航到http://rwa.local/
。
根据本节中的数字,输入 1 作为房间的 ID,并点击加入此房间按钮(参见图 9-5 )。
图 9-5。从主页加入房间(使用不同的浏览器)
房间打开,您现在可以看到“提问”表单(参见图 9-6 )。
图 9-6。您作为与会者查看房间时,会看到“提问”表单
问你的第一个问题
通过在表格字段中键入新问题,测试与会者是否可以提出新问题(参见图 9-7 )。
图 9-7。通过“提问”表单提出新问题
点击提问按钮后,新问题被创建并显示为已被您投票通过(见图 9-8 )。
图 9-8。问题被创建;创造者的投票已经被计算了
投票表决这个问题
为了测试对问题的投票,您需要打开第三个浏览器,这样您就可以作为尚未对新问题投票的与会者加入房间(参见图 9-9 )。
图 9-9。未投票的与会者看到的问题
点击投票按钮,将投票数增加 1(见图 9-10 )。
图 9-10。收到第二次投票后的问题
回答问题
回到第一个浏览器——您的用户是演示者的浏览器——重新加载以查看新问题(参见图 9-11 )。
图 9-11。演示者仪表盘上的问题,完成计票
现在点击回答按钮,将问题标记为已回答(参见图 9-12 )。
图 9-12。演示者眼中的已回答问题
通过检查其他两个浏览器中的任何一个,验证这也能正确显示给与会者(参见图 9-13 )。
图 9-13。与会者眼中的已回答问题
摘要
从这一章到上一章,你已经在最后 100 页左右的篇幅里涉及了很多内容。您现在应该有一个功能齐全的 MVC 应用,允许创建、关闭和重新打开房间;以及提问、投票和回答问题。
在下一章中,您将向应用添加实时事件通知,并实现 JavaScript 效果来动画显示这些事件。
十、实现实时事件和 jQuery 效果
至此,你有了一个全功能的 app。然而,为了让这个特殊的应用有用,它需要实现实时功能,这样它的用户就不会被迫不断地重新加载以获取新数据。
在这一章中,你将把应用挂接到 Pusher 上,并在后端添加代码来创建实时事件。您还将使用 Pusher 的 JavaScript 应用编程接口(API)来订阅这些事件,并使用 jQuery 来制作应用的动画,这样就可以在屏幕上以一种和谐的方式操作新数据。
哪些事件需要实时增强?
- 关闭房间
- 开房
- 问问题
- 投票表决一个问题
- 回答问题
添加所需的凭证和库
在开始添加 realtime 之前,您需要确保手头上有所有适当的凭据和库来配置应用。
获取您的 Pusher API 证书
Pusher API 要求您的应用使用应用密钥、应用密码和应用 ID 进行身份验证。
要获取它们,请在http://pusher.com
登录您的帐户,并从您的仪表板左上角选择“添加新应用”。将你的新应用命名为“实时网络应用”,不要勾选下面的两个框(见图 10-1 )。
图 10-1。在 Pusher 中创建新应用
点击“创建应用”,然后在下一个屏幕上点击“API 访问”,调出您的 API 凭证(见图 10-2 )。
图 10-2。在 Pusher 仪表盘上查看应用的 API 凭证
将推送器 API 凭证添加到配置文件
现在您已经有了 API 凭证,它们需要包含在应用中。为此,向system/config/config.inc.php
添加三个新的常量——以粗体显示:
//----------------------------------------------------------------------------- // Database credentials //----------------------------------------------------------------------------- $_C['DB_HOST'] = 'localhost'; $_C['DB_NAME'] = 'rwa_app'; $_C['DB_USER'] = 'root'; $_C['DB_PASS'] = ''; //----------------------------------------------------------------------------- // Pusher credentials //----------------------------------------------------------------------------- $_C['PUSHER_KEY'] = '9570a71016cf9861a52b'; $_C['PUSHER_SECRET'] = '65cc09ede8e2c18701cc'; $_C['PUSHER_APPID'] = '37506'; //----------------------------------------------------------------------------- // Enable debug mode (strict error reporting) //----------------------------------------------------------------------------- $_C['DEBUG'] = TRUE;
复制
为 Pusher 下载 PHP API 包装
对于应用的后端部分,我们需要使用 API 包装器来使访问 Pusher 变得轻而易举。
从https://github.com/pusher/pusher-php-server
下载 API 包装。ZIP 将包含几个文件和目录,但你需要抓取的唯一一个是在lib/Pusher.php
,你现在应该在你的应用中复制到system/lib/
。
包括应用中的 PHP API 包装器
既然 Pusher API 包装器已经在应用的目录结构中,就需要包含它以供使用。在index.php
中,将以下粗体代码添加到初始化块中:
// Starts the session if (!isset($_SESSION)) { session_start(); } // Loads the configuration variables require_once SYS_PATH . '/config/config.inc.php'; // Loads Pusher require_once SYS_PATH . '/lib/Pusher.php'; // Turns on error reporting if in debug mode if (DEBUG===TRUE) { ini_set('display_errors', 1); error_reporting(E_ALL^E_STRICT); } else { ini_set('display_errors', 0); error_reporting(0); }
复制
加载 Pusher 的 JavaScript API 包装器
对于实时实现的前端部分,应用需要包含 Pusher 的 JavaScript API 包装器。在system/inc/footer.inc.php
中,添加以下粗体代码:
</ul> </footer> <script src="[`js.pusher.com/1.12/pusher.min.js"></script`](http://js.pusher.com/1.12/pusher.min.js"></script)> </body> </html>
复制
正在加载 jQuery
为了获得效果,您的应用将需要 jQuery 库。将其添加到页脚中的 Pusher JS 之后:
</ul> </footer> <script src="http://js.pusher.com/1.12/pusher.min.js"></script> <script src="[`code.jquery.com/jquery-1.8.2.min.js"></script`](http://code.jquery.com/jquery-1.8.2.min.js"></script)> </body> </html>
复制
注意该应用中的代码只经过了 jQuery 1 . 8 . 2 版本的测试。较新的版本可能会带来一些问题,只有经过全面测试后才能使用。
在后端实现实时
为了让球滚动起来,需要在应用的后端创建和触发事件。
创建事件
由于应用处理表单提交的方式——所有表单都通过一个表单处理程序方法传递——发送实时事件通知只需要几行代码,这些代码将被添加到抽象Controller
类中的handle_form_submission()
方法中。
打开system/core/class.controller.inc.php
并插入以下粗体代码:
protected function handle_form_submission( $action ) { if ($this->check_nonce()) { // Calls the method specified by the action $output = $this->{$this->actions[$action]}(); if (is_array($output) && isset($output['room_id'])) { $room_id = $output['room_id']; } else { throw new Exception('Form submission failed.'); } // Realtime stuff happens here $pusher = new Pusher(PUSHER_KEY, PUSHER_SECRET, PUSHER_APPID); $channel = 'room_' . $room_id; $pusher->trigger($channel, $action, $output); header('Location: ' . APP_URI . 'room/' . $room_id); exit; } else { throw new Exception('Invalid nonce.'); } }
复制
创建一个新的Pusher
对象并存储在$pusher
变量中,然后使用其 ID 创建房间的通道。使用动作名作为事件名,使用发送输出数组供客户端使用的trigger()
方法在房间的通道上触发一个新事件。
测试实时事件
为了确保你的实时事件在后端被触发,回到你的 Pusher 仪表盘,打开你的应用的调试控制台。打开此页面,在新标签或浏览器中导航至http://rwa.local
,然后创建一个新房间。
创建房间后,查看调试控制台,您会看到类似于图 10-3 的内容。
图 10-3。推进器调试控制台实时显示房间的创建
接下来,关闭房间;然后重新打开它。再次检查控制台,您将看到close
和open
事件被触发(参见图 10-4 )。
图 10-4。Pusher 控制台显示关闭和重新打开房间的事件
在第二个浏览器中,加入房间(本例中为 ID 5)并提出问题。新的连接以及触发的ask
事件显示在控制台上(见图 10-5 )。
图 10-5。控制台显示新的问题标记,由 ask 事件发送
在第三个浏览器中,对问题进行投票,以查看触发的投票事件(参见图 10-6 )。
图 10-6。控制台显示发生的投票事件
最后,返回到创建房间的浏览器,将问题标记为已回答。这触发了控制台中的answer
事件(参见图 10-7 )。
图 10-7。控制台中的回答事件
在前端实现实时
现在,应用的后端正在触发事件,前端需要监听它们。
订阅频道
第一步是创建一个 JavaScript Pusher
对象,并用它来订阅房间的频道。
确定通道名称
在订阅频道之前,您首先需要创建一个新的模板变量来保存正确的频道名称。在index.php
中,添加以下粗体代码以生成通道名称:
require_once SYS_PATH . '/inc/header.inc.php'; $controller->output_view(); // Configures the Pusher channel if we're in a room $channel = !empty($uri_array[0]) ? 'room_' . $uri_array[0] : 'default'; require_once SYS_PATH . '/inc/footer.inc.php';
复制
添加频道订阅 JavaScript
现在频道名称已经确定,创建一个新的Pusher
对象,并通过在system/inc/footer.inc.php
中添加以下粗体代码来订阅该频道:
</footer> <script src="http://js.pusher.com/1.12/pusher.min.js"></script> <script src="http://code.jquery.com/jquery-1.8.2.min.js"></script> <script> var pusher = new Pusher('<?php echo PUSHER_KEY; ?>'), channel = pusher.subscribe('<?php echo $channel; ?>'); </script> </body> </html>
复制
绑定到事件
该应用现在订阅了一个频道,但此时它仍需要监听各个事件。
创建一个初始化 JavaScript 文件
为了保持页脚的整洁,在assets/scripts/
中创建一个名为init.js
的新文件,并用下面的代码初始化它:
/** * Initialization script for Realtime Web Apps */ (function($) { })(jQuery);
复制
这个文件将包含应用的 JavaScript 的其余部分。
提示用闭包[脚注]包装你的应用脚本可以防止与其他使用美元符号(
$
)快捷方式的库发生冲突,比如 Prototype 和 MooTools。
通过将以下粗体代码插入页脚,将此文件加载到您的应用中:
<script src="http://js.pusher.com/1.12/pusher.min.js"></script> <script src="http://code.jquery.com/jquery-1.8.2.min.js"></script> <script> var pusher = new Pusher('<?php echo PUSHER_KEY; ?>'), channel = pusher.subscribe('<?php echo $channel; ?>'); </script> <script src="<?php echo APP_URI; ?>assets/scripts/init.js"></script> </body> </html>
复制
注意
Pusher
对象初始化和通道订阅被直接放在页脚中,以利用 PHP 支持的模板。
为每个支持的动作添加事件绑定
对于每一个需要实时响应的动作,我们的应用都会触发一个需要“监听”的事件。Pusher 通过bind()
方法使这变得非常容易,这对于任何在以前的项目中使用过 JavaScript 的开发人员来说都应该很熟悉。
bind()
方法将被监听的事件的名称作为第一个参数,将事件发生时要执行的函数作为第二个参数。
使用以下粗体代码为应用中的每个事件绑定一个函数:
(function($) { channel.bind('close', function(data){ }); channel.bind('open', function(data){ }); channel.bind('ask', function(data){ }); channel.bind('vote', function(data){ }); channel.bind('answer', function(data){ }); })(jQuery);
复制
这些绑定实际上不会增加应用的开销,因此同时绑定所有五个不会影响性能。
注意这些方法现在还不做任何事情;您将在下一节中添加该功能。
添加效果
该应用现在发送和接收实时事件,所以剩下要做的就是添加新数据的效果。
处理房间事件
当演示者关闭一个房间时,需要立即让与会者知道,这样他们就不会试图问任何新问题或投新票。
类似地,如果一个演示者重新打开一个房间,所有与会者都应该立即知道这个房间现在又打开了。
由于标记在封闭的房间和开放的房间之间变化很大,所以提醒与会者房间的开放状态发生变化的最直接、最不容易出错的方法是简单地刷新页面。
在init.js
,创建一个名为room
的新对象,它有两个方法:open()
和close()
。两者都应该在被调用时重新加载页面。
相应的事件也应该触发这些方法。为此,将以下粗体代码添加到init.js
:
(function($) { channel.bind('close', function(data){ room.close(data); }); channel.bind('open', function(data){ room.open(data); }); channel.bind('ask', function(data){ }); channel.bind('vote', function(data){ }); channel.bind('answer', function(data){ }); var room = { open: function(data){ location.reload(); }, close: function(data){ location.reload(); } }; })(jQuery);
复制
请注意,我们已经选择在演示者关闭房间时自动重新加载页面。通常情况下,你不希望在没有用户输入的情况下重新加载一个页面,因为这可能会造成混乱,但是我们有充分的理由这样做。在这种特殊情况下,如前所述,当房间关闭时,页面上的许多标记会发生变化。此外,用户不能向关闭的房间提交新问题,因此关闭房间的演示者应该是破坏性的;否则,与会者可能会花额外的时间处理一个无论如何都无法提交的问题,这可能比仅仅意识到房间现在已经关闭,他们应该给演示者发电子邮件更令人沮丧。
添加带动画的新问题
当一个新问题被提出时,它应该立即提供给查看房间的每个人。为了使新数据的引入不那么刺耳,应该添加一个动画。
在第八章中,当动作处理器方法create_question()
被添加到Question
控制器中以询问新问题时,您已经为该事件生成了视图,因此格式化的 HTML 将在data
对象中发送。
但是,因为后端不可能知道视图当前使用的随机数,所以我们需要读取随机数并将其插入到所有新生成的随机数字段的value
属性中,然后用slideDown()
将问题添加到列表的顶部:
(function($) { channel.bind('close', function(data){ room.close(data); }); channel.bind('open', function(data){ room.open(data); }); channel.bind('ask', function(data){ question.ask(data); }); channel.bind('vote', function(data){ }); channel.bind('answer', function(data){ }); varnonce = $('input[name=nonce]:eq(0)').val(), room = { open: function(data){ location.reload(); }, close: function(data){ location.reload(); } }, question = { ask: function(data){ $(data.markup) .find('input[name=nonce]').val(nonce).end() .hide().prependTo('#questions').slideDown('slow'); } }; })(jQuery);
复制
注意为了保持 JavaScript 简洁,我们在这个脚本中对所有三个变量只使用了一次
var
声明。这主要是一种风格上的选择,但是也有一些观点认为使用这种方法可以获得微小的性能提升。
为问题添加投票
当与会者对某个问题进行投票时,投票按钮旁边的计数应该会更新。然而,我们想吸引更多的注意力,所以让我们添加一个微妙的动画。
将动画添加到样式表
因为 CSS3 引入了动画,并且因为大多数现代浏览器支持 CSS3 动画的硬件加速,所以您的应用将使用关键帧 CSS 动画,而不是 jQuery 动画。
为了实现这一点,您首先必须确定哪个类(在本例中为.new-vote
)将触发动画,然后设置它。对于这个名为vote
的动画,我们将快速淡出问题,然后通过调整不透明度再次淡入。这将被执行或迭代两次。
不幸的是,您将需要特定于供应商的前缀来确保动画在所有浏览器中都能工作,所以应该是快速添加的内容变成了相当大量的 CSS。
将以下代码添加到assets/styles/main.css
的底部:
/* * ANIMATION *****************************************************************************/ #questions li.new-vote { -webkit-animation-name: vote; -webkit-animation-duration: 0.5s; -webkit-animation-timing-function: ease-in-out; -webkit-animation-iteration-count: 2; -moz-animation-name: vote; -moz-animation-duration: 0.5s; -moz-animation-timing-function: ease-in-out; -moz-animation-iteration-count:2; -ms-animation-name: vote; -ms-animation-duration: 0.5s; -ms-animation-timing-function: ease-in-out; -ms-animation-iteration-count: 2; animation-name: vote; animation-duration: 0.5s; animation-timing-function: ease-in-out; animation-iteration-count: 2; } @-webkit-keyframes vote { 0% { opacity: 1; } 50% { opacity: .4; } 100% { opacity: 1; } } @-moz-keyframes vote { 0% { opacity: 1; } 50% { opacity: .4; } 100% { opacity: 1; } } @-ms-keyframes vote { 0% { opacity: 1; } 50% { opacity: .4; } 100% { opacity: 1; } } @keyframes vote { 0% { opacity: 1; } 50% { opacity: .4; } 100% { opacity: 1; } }
复制
用一个类触发动画
现在动画已经就绪,JavaScript 需要做的就是添加一个类来触发它。
除了动画之外,脚本还需要更新投票计数,因为可能会有多个人为一个问题投票,所以在动画完成后删除该类,以便它可以被多次触发。
将以下粗体代码添加到init.js
以完成投票效果:
(function($) { channel.bind('close', function(data){ room.close(data); }); channel.bind('open', function(data){ room.open(data); }); channel.bind('ask', function(data){ question.ask(data); }); channel.bind('vote', function(data){ question.vote(data); }); channel.bind('answer', function(data){ }); var nonce = $('input[name=nonce]:eq(0)').val(), room = { open: function(data){ location.reload(); }, close: function(data){ location.reload(); } }, question = { ask: function(data){ $(data.markup) .find('input[name=nonce]').val(nonce).end() .hide().prependTo('#questions').slideDown('slow'); }, vote: function(data){ var question = $('#question-'+data.question_id), cur_count = question.data('count'), new_count = cur_count+1; // Updates the count question .attr('data-count', new_count) .data('count', new_count) .addClass('new-vote'); setTimeout(1000, function(){ question.removeClass('new-vote'); }); } }; })(jQuery);
复制
测试动画
要查看实际效果,请使用两个浏览器加入一个房间(确保没有一个浏览器是演示者),并将它们并排放置,以便您可以同时看到两个浏览器。
在一个浏览器中,问一个新问题;当ask
事件被触发时,它将被动态添加到另一个浏览器窗口。
在另一个浏览器中,对新问题进行投票。提交投票时,观看第一个浏览器:它将在动画中运行一遍,然后返回正常状态。很难在静态图像中演示这一点,但是图 10-8 显示了正在进行的动画。
图 10-8。当使用左边的浏览器对首要问题投票时,动画会在右边的浏览器中触发
用动画和重新排序回答问题
当一个问题被标记为已回答时,它应该淡出,然后从列表中移除(通过向上的动画幻灯片)并重新附加到底部(也通过动画幻灯片),以便为未回答的问题腾出空间。
将以下粗体显示的代码添加到init.js
中,使其发生:
(function($) { channel.bind('close', function(data){ room.close(data); }); channel.bind('open', function(data){ room.open(data); }); channel.bind('ask', function(data){ question.ask(data); }); channel.bind('vote', function(data){ question.vote(data); }); channel.bind('answer', function(data){ question.answer(data); }); var nonce = $('input[name=nonce]:eq(0)').val(), room = { open: function(data){ location.reload(); }, close: function(data){ location.reload(); } }, question = { ask: function(data){ $(data.markup) .find('input[name=nonce]').val(nonce).end() .hide().prependTo('#questions').slideDown('slow'); }, vote: function(data){ var question = $('#question-'+data.question_id), cur_count = question.data('count'), new_count = cur_count+1; // Updates the count question .attr('data-count', new_count) .data('count', new_count) .addClass('new-vote'); setTimeout(1000, function(){ question.removeClass('new-vote'); }); }, answer: function(data){ var question = $("#question-"+data.question_id), detach_me = function() { question .detach() .appendTo('#questions') .slideDown(500); } question .addClass('answered') .delay(1000) .slideUp(500, detach_me); } }; })(jQuery);
复制
测试答题
要查看标记为已回答的问题在与会者看来是什么样子,请打开两个浏览器并将窗口并排放置,以便您可以同时看到两个窗口。在一个浏览器中,创建一个新房间;在另一个房间里,问一个问题。
问题将显示在创建房间的浏览器上,此时您可以将问题标记为已回答。在与会者视图中,问题将淡化、消失,然后以“已回答”状态重新附加(参见图 10-9 )。因为房间里只有一个问题,所以不会演示如何将问题移到列表的底部,但是如果您愿意,您可以进行自己的实验来查看实际效果。
图 10-9。一个在与会者和演示者视图中都被标记为已回答的问题
摘要
在这一章中,你确切地了解了实现实时事件是多么的快捷和容易,以及将它们集成到你的应用中的效果。
至此,应用已经完成,您已经准备好开始构建自己令人惊叹的实时应用了。请在 Twitter 上找到你的作者——@ jlengstorf 和@ leggetter——并分享你的创作。
朋友,欢迎来到网页设计的未来。