Kodokon kodokon.com

PDO: prepared statement และทรานแซกชัน

ตั้งค่า PDO แบบเดียวกับที่ใช้งานจริง กำจัด SQL injection ในเชิงโครงสร้าง และทำให้การเขียนข้อมูลของคุณเชื่อถือได้ด้วยทรานแซกชัน

10 นาที · 3 คำถาม

เปิดบทเรียนนี้ใน Kodokon

PDO คืออินเทอร์เฟซการเข้าถึงฐานข้อมูลแบบรวมศูนย์: ไม่ว่าจะเป็น MySQL, PostgreSQL หรือ SQLite ล้วนขับเคลื่อนด้วย API เดียวกัน มีการตั้งค่าสามอย่างที่แยกการเชื่อมต่อแบบมือใหม่ออกจากการเชื่อมต่อแบบใช้งานจริง ได้แก่ ERRMODE_EXCEPTION เพื่อให้ข้อผิดพลาด SQL ทุกครั้งโยน exception ออกมาแทนที่จะล้มเหลวอย่างเงียบ ๆ, FETCH_ASSOC เป็นค่าเริ่มต้นเพื่อผลลัพธ์ที่สะอาด และ EMULATE_PREPARES ที่ตั้งเป็น false เพื่อให้เซิร์ฟเวอร์ประมวลผล prepared statement จริง ๆ จงเพิ่ม charset=utf8mb4 ลงใน DSN โดยตรง นั่นเป็นที่เดียวที่เชื่อถือได้ในการประกาศมัน

PHP
<?php

declare(strict_types=1);

$dsn = 'mysql:host=localhost;dbname=shop'
    . ';charset=utf8mb4';

$options = [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES => false,
];

$pdo = new PDO($dsn, 'app_user', 'secret', $options);
การเชื่อมต่อแบบใช้งานจริง: exception, การดึงข้อมูลแบบ associative, การเตรียมคำสั่งแบบเนทีฟ

prepared statement คือการป้องกัน SQL injection เชิงโครงสร้างเพียงอย่างเดียวของคุณ หลักการคือ โครงสร้าง ของคำสั่ง พร้อมกับตัวยึดตำแหน่ง :email หรือ ? จะถูกส่งไปยังเซิร์ฟเวอร์ก่อน ส่วน ข้อมูล จะตามมาแยกต่างหากและจะไม่ถูกตีความว่าเป็น SQL เลย การนำตัวแปรมาต่อสายอักขระเข้ากับคำสั่ง แม้จะเป็นตัวที่ "หลีกอักขระ (escape)" แล้ว ก็ยังคงเป็นความผิดพลาดระดับมืออาชีพ ตัวยึดตำแหน่งแบบมีชื่อทำให้โค้ดอ่านง่าย ส่วนแบบระบุตำแหน่งก็เพียงพอสำหรับคำสั่งสั้น ๆ

PHP
<?php

declare(strict_types=1);

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(
    PDO::ATTR_ERRMODE,
    PDO::ERRMODE_EXCEPTION,
);

$pdo->exec(
    'CREATE TABLE users (
        id INTEGER PRIMARY KEY,
        email TEXT NOT NULL
    )'
);

$insert = $pdo->prepare(
    'INSERT INTO users (email) VALUES (:email)'
);
$insert->execute(['email' => 'lea@example.com']);

$query = $pdo->prepare(
    'SELECT id, email FROM users
     WHERE email = :email'
);
$query->execute(['email' => 'lea@example.com']);

var_dump($query->fetch(PDO::FETCH_ASSOC));
ตัวอย่าง SQLite ที่สมบูรณ์ในตัว: การเตรียม การผูกค่า การประมวลผล

ทรานแซกชันรับประกันความเป็นอะตอม (atomicity): การเขียนทั้งหมดสำเร็จ หรือไม่มีอะไรสำเร็จเลย แพตเทิร์นมาตรฐานคือ beginTransaction(), ทำงานภายใน try, commit() ที่ท้ายบล็อก, rollBack() ใน catch จากนั้นคุณ โยน exception ซ้ำ (rethrow) เพราะการซ่อนความล้มเหลวไว้ย่อมเลวร้ายกว่าตัวความล้มเหลวเสียเอง จงทำให้ทรานแซกชันสั้นเข้าไว้ ทุกล็อกที่ถือครองไว้ระหว่างการเรียกผ่านเครือข่ายหรือกระบวนการที่ยาวนานจะบั่นทอนการทำงานพร้อมกัน (concurrency) ทั่วทั้งแอปพลิเคชัน

PHP
<?php

declare(strict_types=1);

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(
    PDO::ATTR_ERRMODE,
    PDO::ERRMODE_EXCEPTION,
);

$pdo->exec(
    'CREATE TABLE accounts (
        id INTEGER PRIMARY KEY,
        balance INTEGER NOT NULL
    )'
);
$pdo->exec(
    'INSERT INTO accounts (balance)
     VALUES (100), (20)'
);

$pdo->beginTransaction();

try {
    $debit = $pdo->prepare(
        'UPDATE accounts
         SET balance = balance - :n
         WHERE id = :id'
    );
    $debit->execute(['n' => 50, 'id' => 1]);

    $credit = $pdo->prepare(
        'UPDATE accounts
         SET balance = balance + :n
         WHERE id = :id'
    );
    $credit->execute(['n' => 50, 'id' => 2]);

    $pdo->commit();
} catch (Throwable $e) {
    $pdo->rollBack();
    throw $e;
}

echo 'Transfer done';
การโอนแบบอะตอม: ทุกอย่างสำเร็จ หรือทุกอย่างถูกย้อนกลับ

ทดสอบความรู้

ตรวจสอบว่าคุณจำประเด็นสำคัญของบทเรียนนี้ได้ครบถ้วน

  1. เหตุใด prepared statement จึงสกัด SQL injection ได้?
    • มันหลีกอักขระเครื่องหมายอะพอสทรอฟีในสายอักขระโดยอัตโนมัติ
    • โครงสร้าง SQL และข้อมูลเดินทางแยกกัน: ข้อมูลจะไม่ถูกตีความว่าเป็นโค้ด SQL เลย
    • มันเข้ารหัสคำสั่งระหว่าง PHP กับเซิร์ฟเวอร์
  2. คุณส่งอะไรผ่านตัวยึดตำแหน่ง :param ใน prepared statement ได้บ้าง?
    • ค่าหนึ่ง ๆ: สายอักขระ ตัวเลข หรือ null
    • ชื่อตารางหรือชื่อคอลัมน์
    • ประโยค ORDER BY แบบเต็ม
  3. ในแพตเทิร์นทรานแซกชันมาตรฐาน คุณทำอะไรในบล็อก catch?
    • commit() เพื่อบันทึกส่วนที่สำเร็จ
    • rollBack() แล้วโยน exception ซ้ำ
    • ไม่ต้องทำอะไร: ทรานแซกชันจะหมดอายุไปเอง