0%

[week 12] 利用 PHP 實作留言板 - API 篇

本篇為 [BE101] 用 PHP 與 MySQL 學習後端基礎 這門課程的學習筆記。如有錯誤歡迎指正。

hw1:JavaScript 留言板

參考筆記

  • [week 4] 網路基礎概論 - HTTP 協定、TCP/IP、API
  • [week 8] 傳送資料的方式 & 瀏覽器的限制

什麼是 API?

API 就是純資料的交換。資料以 JSON 形式儲存。

在第八週時,我們學會使用 JavaScript 來串接 API,前端負責顯示資料,後端只負責提供資料。

之前實作的留言板是透過 PHP 直接輸出內容。這週我們會透過 PHP 實作 API,再使用 JavaScript 串接 API 來動態顯示資料。

如何測試 API

有幾種方式能夠測試 API 是否能成功運行。可參考這篇文章介紹:API 實作(三):以 Postman 測試 API

  • 瀏覽器:撰寫程式碼不易,步驟繁瑣
  • curl 工具:不易進行 debug
  • Postman:方便使用,能夠針對不同分頁或欄位進行測試

練習:實作無會員機制的留言版 API

PHP 相關語法

  • header('Content-Type: application/json; charset=utf-8');:指定瀏覽器以 JSON 格式內容,UTF-8 字元編碼
  • array_push():在一個陣列中,再插入一個值進去
    • 語法:array_push(欲增加的陣列, 值)
      <?php 
        $array = array(); 
        array_push($array, "Test"); 
        print_r($array); 
      ?>
      // 輸出結果: Array ( [0] => Test )

用 PHP 實作 API

首先要瞭解如何使用 PHP 做出 API,以 api_comments.php 下列程式碼為例:

<?php
  // 宣告變數 comments 為空陣列
  $comments = array();
  // 把資料放到陣列 $comments,裡面再建立陣列 array
  array_push($comments, array(
    "id" => 1,
    "username" => "aaa",
    "content" => "123"
  ));
  array_push($comments, array(
    "id" => 2,
    "username" => "bbb",
    "content" => "456"
  ));
  
  $json = array(
    "comments" => $comments
  );

  $response = json_encode($json);
  // 讓瀏覽器知道我們要印出 JSON 格式
  header('Content-Type: application/json; charset=utf-8');
  echo $response;
?>

在瀏覽器接收到的 response 就是 JSON 格式的物件,可使用開發者工具查看內容:

實作 API:列出所有文章

把之前實作留言板 index.php 時,使用的語法結合到 api_comments.php,即可得到只輸出資料的 API:

<?php
  require_once("conn.php");

  // 和 index.php 抓取資料的語法相同
  $page = 1;
  if (!empty($_GET['page'])) {
    $page = intval($_GET['page']);
  }
  $items_per_page = 5;
  $offset = ($page - 1) * $items_per_page; 

  $sql =  "SELECT ".
            "C.id as id, C.content AS content, ". 
            "C.created_at AS created_at, U.nickname AS nickname, U.username AS username ".
          "FROM heidi_comments AS C ". 
          "LEFT JOIN heidi_users AS U ON C.username = U.username ".
          "WHERE C.is_deleted IS NULL ".
          "ORDER BY C.id DESC ".
          "LIMIT ? OFFSET ? ";
  $stmt = $conn->prepare($sql);
  $stmt->bind_param("ii", $items_per_page, $offset);
  $result = $stmt->execute();
  if (!$result) {
    die('Error:' . $conn->error);
  }
  $result = $stmt->get_result();
  $comments = array();

  // 把讀取的資料放到陣列 $comments,裡面再建立陣列 array,概念比較像 JS 物件
  while($row = $result->fetch_assoc()) {
    array_push($comments, array(
    "id" => $row['id'],
    "username" => $row['username'],
    "nickname" => $row['nickname'],
    "content" => $row['content'],
    "created_at" => $row['created_at']
    ));
  }
  
  $json = array(
    "comments" => $comments
  );

  $response = json_encode($json);
  // 讓瀏覽器知道我們要印出 JSON 格式
  header('Content-Type: application/json; charset=utf-8');
  echo $response;
?>

上述程式碼,和 index.php 同樣是讀取資料,差別在於 API 是把資料放到陣列 $comments,裡面再建立陣列 array,概念比較像 JS 物件。

實作 API:新增文章

api_add_comment.php 為例,寫法會和 handle_add_comment.php(新增留言功能)的邏輯類似:

<?php
  require_once('conn.php');

  header('Content-Type: application/json; charset=utf-8');
  // 若讀取失敗
  if (
    empty($_POST['content'])
  ) {
    $json = array(
      "ok" => false,
      "message" => "Please input content"
    );

    $response = json_encode($json);
    echo $response;
    die(); 
  }
 
  $username = $_POST['username'];
  $content = $_POST['content'];

  $sql = "INSERT INTO heidi_comments(username, content)
    VALUES(?, ?)";
  $stmt = $conn->prepare($sql);
  $stmt->bind_param('ss', $username, $content);
  $result = $stmt->execute();
  // 若執行失敗
  if (!$result) {
    $json = array(
      "ok" => false,
      "message" => $conn->error
    );

    $response = json_encode($json);
    echo $response;
    die(); 
  }
  // 若成功讀取資料
  $json = array(
      "ok" => true,
      "message" => "Success"
    );

    $response = json_encode($json);
    echo $response;
?>

前端串接 API

最後就是在前端頁面 index.html 串接寫好的 API:

<body>
  <div class="wrapper">
    <main class="board"> 
      <div class ="board__header">
        <h1 class="board__tittle">Comments</h1>
        <div class="board__btn-block"> 
        </div>
      </div>
  
      <form class="board__new-comment-form">
        <textarea name="content" rows="5" placeholder="請輸入留言..."></textarea>
        <input class="board__submit-btn" type="submit">
      </form>
    <div class="board__hr"></div>

    <section>
       // 動態新增留言的區塊...
    </section>
    </main>
  </div>

  <script>
      // 發出 Request
      var request = new XMLHttpRequest();
      request.open('GET', 'api_comments.php', true);

      request.onload = function() {
        if (this.status >= 200 && this.status < 400) {
          var resp = this.response;
          var json = JSON.parse(resp)
          var comments = json.comments

          for (var i = 0; i < comments.length; i++) {
            var comment = comments[i]
            var div = document.createElement('div')
            div.classList.add('card')
            div.innerHTML = `
              <div class="card__avatar"></div>
              <div class="card__body">
                  <div class="card__info">
                    <span class="card__author">
                      ${encodeHTML(comment.nickname)}(@${encodeHTML(comment.username)})
                    </span>
                    <span class="card__time">
                      ${encodeHTML(comment.created_at)}
                    </span>
                  </div>
                  <p class="card__content">${encodeHTML(comment.content)}</p>
              </div>
            `
            document.querySelector('section').appendChild(div)
          }
        } 
      };
      request.send();

      var form = document.querySelector('.board__new-comment-form')
      form.addEventListener('submit', function(e) {
        // 阻止預設事件: 送出表單
        e.preventDefault()
        // 讀取輸入內容
        var content = document.querySelector('textarea[name=content]').value
        var request = new XMLHttpRequest();
        // 發出 POST Request
        request.open('POST', 'api_add_comment.php', true);
        request.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
        request.send("username=aaa&content=" + encodeURIComponent(content));
        request.onload = function() {
          if (this.status >= 200 && this.status < 400) {
            var resp = this.response;
            var json = JSON.parse(resp)
            if (json.ok) {
              // 頁面重整: 可重新抓取留言
              location.reload()
            } else {
              alert(json.message)
            }       
          }
        }
      })
      // 字串均需進行 escape 跳脫
      function encodeHTML(s) {
        return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
      }
  </script>
</body>

實戰:增強版 JavaScript 留言板

接著要來打造後端 API,再利用前端 JavaScript 來串接 API 實作留言板功能。

建立後端 API

Step1. 建立資料庫 discussions

  • id
  • site_key
  • nickname
  • content
  • created_at

Step2. 新增留言功能 api_add_comments.php

<?php
  require_once('conn.php');
  // 讓瀏覽器知道回覆的資料是 JSON 格式
  header('Content-Type: application/json; charset=utf-8');
  // 錯誤處理: 確認資料是否為空值
  if (
    empty($_POST['nickname']) ||
    empty($_POST['site_key']) ||
    empty($_POST['content'])
    ) {
    $json = array(
      "ok" => false,
      "message" => "Please input content"
    );
    $response = json_encode($json);
    echo $response;
    die();
  }

  $nickname = $_POST['nickname'];
  $site_key = $_POST['site_key'];
  $content = $_POST['content'];

  $sql = "INSERT INTO heidi_discussions(site_key, nickname, content) VALUES (?, ?, ?)";
  $stmt = $conn->prepare($sql);
  $stmt->bind_param('sss', $site_key, $nickname, $content);
  $result = $stmt->execute();
  // 錯誤處理: 確認是否執行成功
  if (!$result) {
    $json = array(
      "ok" => false,
      "message" => $conn->error     // 通常不會直接顯示錯誤訊息,因為可能包含敏感資訊
    );
    $response = json_encode($json);
    echo $response;
    die();
  }
  // 成功拿到資料
  $json = array(
    "ok" => true,
    "message" => "success"
  );
  // 把建立好的 $json 物件,轉成 JSON 字串輸出
  $response = json_encode($json);
  echo $response;
?>

利用 postman 以 POST 方式發出 request 測試,確認是否能新增留言到資料庫:

postmanTest

Step3. 顯示留言功能 api_comments.php

<?php
  require_once('conn.php');
  // 讓瀏覽器知道回覆的資料是 JSON 格式
  header('Content-Type: application/json; charset=utf-8');
  // 用 site_key 來區分不同的留言版
  if (
    empty($_GET['site_key'])
    ) {
    $json = array(
      "ok" => false,
      "message" => "Please add site_key in url"
    );

    $response = json_encode($json);
    echo $response;
    die();
  }

  $site_key = $_GET['site_key'];

  $sql = "SELECT nickname, content, created_at FROM heidi_discussions WHERE site_key = ? ORDER BY id DESC";
  $stmt = $conn->prepare($sql);
  $stmt->bind_param('s', $site_key);
  $result = $stmt->execute();
  // 錯誤處理: 確認是否執行成功
  if (!$result) {
    $json = array(
      "ok" => false,
      "message" => $conn->error
    );
    $response = json_encode($json);
    echo $response;
    die();
  }
  // 若執行成功就拿取資料
  $result = $stmt->get_result();
  $discussions = array();
  while($row = $result->fetch_assoc()) {
    array_push($discussions, array(
      "nickname" => $row["nickname"],
      "content" => $row["content"],
      "created_at" => $row["created_at"]
    ));
  }

  $json = array(
    "ok" => true,
    "discussions" => $discussions
  );
  // 把建立好的 $json 物件,轉成 JSON 字串輸出
  $response = json_encode($json);
  echo $response;
?>

利用 postman 以 GET 方式發出 request 測試,確認是否能讀取留言:

這樣就完成後端 API 的新增留言和顯示留言功能。

前端串接 API

Step1. 建立 UI 頁面

首先利用 Bootstrap 來快速建立前端頁面 index.html

Step2. 將前端頁面串接 API

  • 顯示留言 API
  • 新增留言 API
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Week12 留言板</title>
  <!-- 引入 jQuery -->
  <script src="https://code.jquery.com/jquery-3.5.1.js"></script>
  <!-- 引入 Bootstrap -->
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" integrity="sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z" crossorigin="anonymous">
  <style>
    .add-comment-form {
      margin-bottom: 10px;
    }
    .card {
      margin-bottom: 10px;
    }
    .card-body h5, .card-body span {
      display: inline-block;
      margin-right: 20px;
    }
  </style>
  <script>
    // 跳脫函式
    function escape(toOutput) {
      return toOutput
        .replace(/&/g, '&')
        .replace(/</g, '<')
        .replace(/>/g, '>')
        .replace(/"/g, '"')
        .replace(/'/g, '&#039;');
    }
    // 渲染 comment: 處理讀取的資料 & 決定加在最前面或最後面
    function appendCommentToDOM(container, comment, isPrepend) {
      const html = `
        <div class="card">
          <div class="card-body">
            <h5 class="card-title">${escape(comment.nickname)}</h5>
            <span>${escape(comment.created_at)}</span>
            <p class="card-text">${escape(comment.content)}
            </p>
          </div>
        </div>
      `;
      if (isPrepend) {
        container.prepend(html);
      } else {
        container.append(html);
      }
    }

    const showUrl = 'http://localhost/heidi/week12_local/hw1/api_comments.php?site_key=heidi';
    const addUrl = 'http://localhost/heidi/week12_local/hw1/api_add_comments.php';

    $(document).ready(() => {
      // 顯示留言
      const commentDOM = $('.comments')
      $.ajax({
        url: showUrl,
      }).done(function (data) {
        if (!data.ok) {
          alert(data.message);
          return;
        }
        // 若 request 成功讀取資料
        const comments = data.discussions;
        for (let comment of comments) {
          appendCommentToDOM(commentDOM, comment);
        }
      });
      
      // 新增留言: 將資料存到後端
      $('.add-comment-form').submit(e => {
        e.preventDefault();             // 取消原生行為 -> 不會送出表單
        const newCommentData = {
          'site_key': 'heidi',
          'nickname': $('input[name=nickname]').val(),
          'content': $('textarea[name=content]').val()
        }
        $.ajax({
          type: 'POST',
          url: addUrl,
          data: newCommentData
        }).done(function(data) {        // done(): 以函數處理回傳的 data 資料
          // 執行失敗
          if (!data.ok) {
            alert(data.message);
            return;
          }
          // 執行成功: 按下送出後把欄位清空
          $('input[name=nickname]').val('');
          $('textarea[name=content]').val('');
          // 新增留言後以 JS 動態方式加到最上方
          appendCommentToDOM(commentDOM, newCommentData, true);
        });
      });
    });
  </script>
</head>

<body>
  <div class="container">
    <form class="add-comment-form">
      <div class="form-group">
        <label for="form-nickname">暱稱</label>
        <input name="nickname" type="text" class="form-control" id="form-nickname" >
      </div>
      <div class="form-group">
        <label for="content-textarea">留言內容</label>
        <textarea name="content" class="form-control" id="exampleFormControlTextarea1" rows="3"></textarea>
      </div>
      <button type="submit" class="btn btn-dark">送出</button>
    </form>
    <div class="comments">
      <!-- 以 JavaScript 動態顯示資料的區塊 -->
    </div>
  </div>

</body>
</html>

Step3. 實作分頁機制

  • Offset/limit-based Pagination

    • 基於 Offset/limit 的分頁,也就是我們在 week11 實作的留言板
    • 可計算資料的總數量、目前頁數,或跳到指定的頁數
    • 缺點:當資料量大時執行緩慢
-- 跳過 5 筆資料,回傳接下來的 5 筆資料
SELECT * FROM comments ORDER BY id DESC LIMIT 5 OFFSET 5
  • Cursor-based pagination

    • 基於 Cursor(指標)的分頁
    • 可透過指定明確的起始點(Pointer)來回傳資料,例如:id 或 created_at
    • 缺點:沒有「總和」和「頁數」的概念

相關函式

// 再包一層 function 避免重複輸入同樣的程式碼
function getComments() {
  const commentDOM = $('.comments');
  $('.load-more').hide();         // 點擊後就隱藏按鈕
  if (isEnd) {
    return;          // 若拿完資料就直接返回
  }
  getCommentsAPI(siteKey, lastId, data => {
    if (!data.ok) {
      alert(data.message);
      return;
    }
    // 若 request 成功讀取資料
    const comments = data.discussions;
    for (let comment of comments) {
      appendCommentToDOM(commentDOM, comment);
    }
    let length = comments.length;
    // 沒有 lastId: 初始頁面的留言若 < 5 直接返回
    if (!lastId && length < 5) {
      return (comments.length < 5);
    } 
    // 有 lastId: 若拿完資料就隱藏按鈕
    if (length === 0) {
      isEnd = true;
      $('.load-more').hide();
    } else {
      lastId = comments[length - 1].id;
      $('.comments').append(loadMoreButtonHTML);   // 新增 "載入更多" 按鈕
    }
  });
}

參考資料:

  • 深入淺出 GraphQL Pagination 實作

debug

錯誤一 Reason

Reason: CORS header 'Access-Control-Allow-Origin' missing
  • 原因:缺少表頭 header('Access-Control-Allow-Origin: *');
  • 實際情況:可能是 php 檔語法上有錯誤,才會出現這個錯誤訊息

錯誤二 TypeError

TypeError: Cannot read property 'replace' of undefined

  • 原因:要進行跳脫的值為 null
  • 解決辦法:先判斷該值是否為空再進行 replace 操作

參考網站:Cannot read property ‘replace’ of undefined