早期 PHP 專案常使用 ODBC 連接 Microsoft SQL Server,並自行撰寫分頁類別。隨著 PHP 版本演進,PDO (PHP Data Objects) 已成為資料庫存取的標準介面,提供更好的安全性、可攜性與效能。本文先解析傳統 ODBC 分頁類別的運作原理,再展示現代 PDO 重構版本,並比較兩者優劣。
<?php
// 傳統寫法(教學用,不建議生產環境使用)
class LegacyPages {
public $cn, $d, $result, $dsn, $user, $pass;
public $total, $pages, $onepage, $page, $fre, $nxt, $i, $nums;
public function getConnect($dsn, $user, $pass) {
$this->cn = @odbc_connect($dsn, $user, $pass);
if (!$this->cn) die("資料庫連線失敗");
}
public function getDo($sql) {
$this->d = @odbc_do($this->cn, $sql);
if (!$this->d) die("查詢錯誤");
return $this->d;
}
public function getTotal($sql) {
$dT = $this->getDo($sql);
$this->total = odbc_result($dT, 'total');
return $this->total;
}
public function getList($sql, $onepage, $page) {
$this->onepage = $onepage;
$this->page = $page ?: 1;
$this->dList = $this->getDo($sql);
$this->pages = ceil($this->total / $this->onepage);
$this->fre = $this->page - 1;
$this->nxt = $this->page + 1;
$this->nums = ($this->page - 1) * $this->onepage;
// 注意:ODBC 無 LIMIT,需在應用層跳過記錄
return $this->dList;
}
public function getFanye() {
$str = '';
if ($this->page != 1) {
$str .= "首頁 ";
$str .= "fre}'>上頁 ";
}
if ($this->page < $this->pages) {
$str .= "nxt}'>下頁 ";
$str .= "pages}'>尾頁 ";
}
$str .= "共 {$this->pages} 頁,目前第 {$this->page} 頁";
return $str;
}
}
// 使用方式
$pg = new LegacyPages();
$pg->getConnect('mssql_dsn', 'sa', 'password');
$pg->getTotal("SELECT COUNT(*) AS total FROM students");
$pg->getList("SELECT name FROM students ORDER BY id", 10, $_GET['page'] ?? 1);
// 手動跳過記錄(ODBC 無 OFFSET)
for ($i = 0; $i < $pg->nums; $i++) odbc_fetch_row($pg->dList);
// 顯示當頁資料
$count = 0;
while (odbc_fetch_row($pg->dList) && $count < $pg->onepage) {
echo odbc_result($pg->dList, 'name') . "
";
$count++;
}
echo $pg->getFanye();
?>
LIMIT/OFFSET,需在 PHP 迴圈中 odbc_fetch_row() 跳過大量記錄(深分頁極慢)。odbc_free_result()、odbc_close()。@ 抑制錯誤配合 die(),難以整合日誌系統。<?php
/**
* 現代化分頁類:支援 PDO、參數綁定、多種資料庫、分離關注點
*/
class Paginator
{
private PDO $pdo;
private string $baseUrl;
private int $perPage;
private int $currentPage;
private int $totalItems = 0;
private int $totalPages = 0;
public function __construct(PDO $pdo, string $baseUrl = '', int $perPage = 15)
{
$this->pdo = $pdo;
$this->baseUrl = $baseUrl ?: rtrim($_SERVER['PHP_SELF'], '/');
$this->perPage = max(1, $perPage);
$this->currentPage = max(1, (int)($_GET['page'] ?? 1));
}
/**
* 設定查詢並取得分頁資料
* @param string $sql 基礎查詢(不含 LIMIT/OFFSET),可含 ? 或 :name 佔位符
* @param array $params 參數陣列
* @param string $countSql 可選:自訂計數查詢(複雜 JOIN 時需指定)
* @return array 當頁資料陣列
*/
public function paginate(string $sql, array $params = [], ?string $countSql = null): array
{
// 1. 計算總筆數
if ($countSql === null) {
// 簡單移除 ORDER BY、SELECT 欄位,包裝 COUNT(*)
$countSql = preg_replace('/^\s*SELECT\s+.*?\s+FROM\s+/is', 'SELECT COUNT(*) FROM ', $sql);
$countSql = preg_replace('/\s+ORDER\s+BY\s+.*$/is', '', $countSql);
}
$stmt = $this->pdo->prepare($countSql);
$stmt->execute($params);
$this->totalItems = (int)$stmt->fetchColumn();
$this->totalPages = (int)ceil($this->totalItems / $this->perPage);
// 修正當前頁
if ($this->currentPage > $this->totalPages) $this->currentPage = max(1, $this->totalPages);
// 2. 取得當頁資料(支援 MySQL/PostgreSQL/SQLite LIMIT,SQL Server OFFSET/FETCH)
$offset = ($this->currentPage - 1) * $this->perPage;
$driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
if ($driver === 'sqlsrv' || $driver === 'dblib' || $driver === 'mssql') {
// SQL Server 2012+ OFFSET/FETCH
$sql .= " ORDER BY (SELECT 0) OFFSET ? ROWS FETCH NEXT ? ROWS ONLY";
$params[] = $offset;
$params[] = $this->perPage;
} else {
// MySQL, PostgreSQL, SQLite
$sql .= " LIMIT ? OFFSET ?";
$params[] = $this->perPage;
$params[] = $offset;
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* 取得分頁導航 HTML(Bootstrap 5 風格)
*/
public function links(array $extraParams = []): string
{
if ($this->totalPages <= 1) return '';
$query = array_merge($_GET, $extraParams);
unset($query['page']);
$html = '';
return $html;
}
/**
* 取得分頁資訊陣列(供 API/JSON 使用)
*/
public function toArray(): array
{
return [
'current_page' => $this->currentPage,
'per_page' => $this->perPage,
'total_items' => $this->totalItems,
'total_pages' => $this->totalPages,
'has_more' => $this->currentPage < $this->totalPages,
];
}
private function url(array $query): string
{
return $this->baseUrl . '?' . http_build_query($query);
}
// Getter
public function totalItems(): int { return $this->totalItems; }
public function totalPages(): int { return $this->totalPages; }
public function currentPage(): int { return $this->currentPage; }
public function perPage(): int { return $this->perPage; }
}
?>
<?php
$dsn = 'sqlsrv:Server=localhost;Database=mydb';
$pdo = new PDO($dsn, 'sa', 'password', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, // 使用真實預備語句
]);
?>
<?php
require 'Paginator.php';
$paginator = new Paginator($pdo, '/students.php', 10);
// 參數化查詢,防止 SQL 注入
$keyword = $_GET['q'] ?? '';
$params = [];
$sql = "SELECT id, name, email, created_at FROM students WHERE 1=1";
if ($keyword) {
$sql .= " AND (name LIKE ? OR email LIKE ?)";
$params[] = "%$keyword%";
$params[] = "%$keyword%";
}
$sql .= " ORDER BY created_at DESC";
$students = $paginator->paginate($sql, $params);
$paginationHtml = $paginator->links(['q' => $keyword]);
$paginationData = $paginator->toArray(); // 給 AJAX 用
?>
<table class="table table-striped">
<thead><tr><th>ID</th><th>姓名</th><th>Email</th><th>建立時間</th></tr></thead>
<tbody>
<?php foreach ($students as $s): ?>
<tr>
<td><?= $s['id'] ?></td>
<td><?= htmlspecialchars($s['name']) ?></td>
<td><?= htmlspecialchars($s['email']) ?></td>
<td><?= $s['created_at'] ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?= $paginationHtml ?>
| 優化點 | 說明 |
|---|---|
| 索引覆蓋 | 確保 WHERE/ORDER BY 欄位有索引,避免全表掃描 |
| 避免 SELECT * | 只查詢需要的欄位,減少網路傳輸與記憶體 |
| 深分頁優化 | 大資料集(>10萬筆)深分頁時,改用「游標分頁」:WHERE id > ? ORDER BY id LIMIT ? |
| 快取總筆數 | 總筆數變動不頻繁時,Redis 快取 COUNT(*) 結果 |
| 連線池 | 生產環境使用 PHP-FPM + PDO 連線重用,或 Swoole/RoadRunner 長連線 |
從 ODBC 手寫分頁邁向 PDO 參數化分頁,不僅是語法更新,更是安全性、可維護性、效能的全面升級。現代分頁類別應具備:驅動無關性(自動處理 LIMIT/OFFSET/FETCH 差異)、關注點分離(資料取得與 HTML 輸出分離)、防注入(全程參數綁定)、可測試性(依賴注入 PDO)。遺留專案建議逐步重構,新專務必採用 PDO + 參數綁定作為基準。