ย้ายบอร์ด vBulletin 4.2.5 ไป Flarum: บทเรียนจากสองแสนโพสต์
ฟอรัมเก่าที่รันมาสิบกว่าปีย่อมมีข้อมูลสะสมเยอะ — พอถึงวันต้องย้ายจาก vBulletin 4.2.5 ไป Flarum ปัญหาไม่ได้อยู่ที่ "ย้ายได้ไหม" แต่อยู่ที่รายละเอียดเล็ก ๆ ที่ทำ import ล้มทั้งชุดตอนตีสาม บทความนี้คือบันทึกกับดักที่ผมเจอกับตัวเอง
พอดีบอร์ด vBulletin 4.2.5 ตัวเก่าถึงเวลาต้องเลิกใช้ — โค้ดเบสหยุดพัฒนา ค่าไลเซนส์ก็ยังต้องจ่าย เลยตัดสินใจย้ายไป Flarum ซึ่งเป็น PHP ตัวใหม่ น้ำหนักเบากว่า และแก้ธีมง่ายกว่ามาก ตัวเลขที่ต้องขนย้ายคือสมาชิกราวเจ็ดพันกว่าคน กระทู้สองหมื่นกว่า และโพสต์สองแสนกว่า งานแบบนี้ถ้ารันสคริปต์รวดเดียวแล้วเชื่อว่าจบ มักจะเจ็บตอนตีสาม เลยอยากบันทึกกับดักที่เสียเวลาจริงไว้ เผื่อใครกำลังจะย้ายบอร์ดตัวเองจะได้ไม่ตกหลุมเดิม
ชื่อฐานข้อมูล ชื่อ user และ IP ในบทความเป็นตัวอย่าง ส่วนโครงสร้างตารางกับลำดับขั้นตอนคือของจริง
แผนใหญ่: อย่า import ทั้งก้อนทีเดียว
สิ่งแรกที่ต้องยอมรับคือ schema ของสองตัวนี้คนละโลกกัน จะ dump ตารางเก่ายัดเข้าตารางใหม่ตรง ๆ ไม่ได้ ต้อง map ทีละชุดตามลำดับ dependency — ของที่ถูกอ้างถึง (users, tags) ต้องเข้าก่อนของที่อ้างถึงมัน (discussions, posts) เสมอ
ตัว "forum" ของ vBulletin ผมแปลงเป็น "tag" ของ Flarum เพราะ Flarum ไม่มีคอนเซ็ปต์ forum แบบซ้อนชั้น ใช้ tag แทนหมด ส่วน thread กลายเป็น discussion และ post ก็คือ post เหมือนเดิม — แต่รายละเอียดข้างในนี่แหละที่เป็นเรื่อง
user_id = 0 กับ FK ที่พังทั้งชุด
กับดักแรกที่เจอ: vBulletin ใช้ userid = 0 แทน guest (คนโพสต์โดยไม่ล็อกอิน หรือ user ที่ถูกลบไปแล้ว) แต่ Flarum วาง foreign key ไว้ว่า posts.user_id ต้องชี้ไปหา user ที่มีจริง หรือไม่ก็เป็น NULL เท่านั้น พอ import โพสต์ที่ userid=0 เข้าไปตรง ๆ FK constraint เด้งทันที insert ล้มทั้ง batch
ทางแก้มีสองจุด: แปลง 0 เป็น NULL ตอน map และปิด FK check ระหว่าง bulk insert เพราะเราคุมลำดับเองอยู่แล้ว
-- ปิด FK check ทั้ง session ก่อน bulk insert
-- (เราคุมลำดับ users -> posts เองแล้ว ไม่ต้องให้ engine เช็คทีละแถวให้ช้า)
SET FOREIGN_KEY_CHECKS = 0;
INSERT INTO flarum_db.posts (id, discussion_id, user_id, type, content, created_at)
SELECT p.postid,
p.threadid,
NULLIF(p.userid, 0) AS user_id, -- guest 0 -> NULL กันชน FK
'comment',
p.pagetext, -- ยังเป็น BBCode ตรงนี้ เดี๋ยวแปลงทีหลัง
FROM_UNIXTIME(p.dateline)
FROM vb_db.post p;
SET FOREIGN_KEY_CHECKS = 1;
ปิด FOREIGN_KEY_CHECKS คือการเชื่อว่าเราจะไม่ทำ orphan row หลุดเข้าไป ถ้าเผลอ import posts ที่ discussion_id ชี้ไปหากระทู้ที่ยังไม่ได้ย้าย มันจะยอมรับเงียบ ๆ แล้วไปพังตอน user เปิดอ่าน จบงานแต่ละชุดควรนับแถวเทียบสองฝั่งเสมอ
รหัสผ่านเก่า: md5 ซ้อน md5 อย่าไปแตะ
ประเด็นที่คนถามมากสุดคือ "ย้ายแล้วสมาชิกต้องตั้งรหัสใหม่ไหม" — คำตอบคือไม่ต้อง ถ้าทำถูกวิธี รหัสผ่าน vBulletin เก็บเป็น
vBulletin: md5( md5(password) . salt ) -- salt ต่อท้าย hash ชั้นใน
เราแฮชกลับไม่ได้อยู่แล้ว (มันคือ hash) เพราะฉะนั้นห้าม re-hash ห้ามแปลงอะไรทั้งสิ้น — ให้ยกทั้ง hash เดิมและ salt เดิมมาตรง ๆ แล้วให้ extension migratetoflarum/old-passwords เป็นคนตรวจตอนสมาชิก login ครั้งแรก ตั้งค่าอัลกอริทึมเป็น md5-double และบอกว่า salt อยู่ตำแหน่ง salt-after (ต่อท้าย) ให้ตรงกับสูตร vBulletin
-- ยก hash เดิม + salt เดิมมาทั้งดุ้น ห้ามแปลง
INSERT INTO flarum_db.users (id, username, email, password, joined_at)
SELECT u.userid, u.username, u.email, u.password, FROM_UNIXTIME(u.joindate)
FROM vb_db.user u;
-- salt เดิมเก็บไว้ให้ extension เอาไปต่อท้ายตอนตรวจ (salt-after)
UPDATE flarum_db.users f
JOIN vb_db.user u ON u.userid = f.id
SET f.migratetoflarum_old_password_salt = u.salt;
พอสมาชิกล็อกอินสำเร็จครั้งแรก extension จะตรวจ md5-double กับรหัสที่กรอก ถ้าผ่านมันจะ re-hash รหัสนั้นเป็น bcrypt ของ Flarum ให้อัตโนมัติ hash เก่าถูกทิ้ง — แปลว่ายิ่งเวลาผ่านไป ฐานข้อมูลก็ค่อย ๆ อัปเกรดรหัสเป็นมาตรฐานใหม่เองโดยสมาชิกไม่รู้ตัว
schema ไม่เหมือนกัน: sticky/lock/approve ไปอยู่ไหน
vBulletin เก็บสถานะกระทู้ (ปักหมุด, ล็อก, อนุมัติ) เป็นคอลัมน์ในตาราง thread ตรง ๆ แต่ Flarum ตัด core ให้บางลง — ตาราง discussions ไม่มี is_sticky, is_locked, is_approved เลย ของพวกนี้ย้ายไปอยู่ที่ extension กับ soft-delete pattern แทน
ทุกสถานะอยู่ในตาราง thread
sticky, open (lock), visible (approve) เป็นคอลัมน์ในตาราง thread ตรง ๆ query ง่ายแต่ core บวม
แยกออก extension + soft-delete
ปักหมุด/ล็อกอยู่ที่ flarum/sticky + flarum/lock ส่วน is_approved กลายเป็น hidden_at (NULL = โชว์, มีค่า = ซ่อน)
ผลคือตอน map ต้องรู้ว่าค่าเก่าไปลงคอลัมน์ไหนใหม่ ตัวอย่าง sticky กับ lock ต้อง set หลัง import discussion เสร็จ:
-- ปักหมุด: flarum/sticky อ่านคอลัมน์ is_sticky ที่ extension เพิ่มเข้ามา
UPDATE flarum_db.discussions f
JOIN vb_db.thread t ON t.threadid = f.id
SET f.is_sticky = 1
WHERE t.sticky = 1;
-- ล็อก: flarum/lock ใช้ is_locked ; vBulletin open=0 คือปิด (ล็อก)
UPDATE flarum_db.discussions f
JOIN vb_db.thread t ON t.threadid = f.id
SET f.is_locked = 1
WHERE t.open = 0;
Flarum เก็บสถานะซ่อนไว้ทั้ง discussions.hidden_at และ posts.hidden_at แก้แค่ตาราง discussions เนื้อหายังโผล่ให้คนนอกเห็นได้ เช่นเดียวกับ is_private ของ fof/byobu — เวลา flip เป็น public ต้องเคลียร์ทั้ง discussions และ posts ไม่งั้นบางคนเห็นบางคนไม่เห็น แล้วต้อง php flarum cache:clear ตามทุกครั้ง
BBCode เป็นสองแสนโพสต์ ต้องแปลงเป็น s9e XML
ตอน insert posts ข้างบน ผมยัด pagetext ดิบ ๆ ที่ยังเป็น BBCode เข้าไปก่อน แต่ Flarum ไม่ได้เก็บ markdown/BBCode ดิบในคอลัมน์ content — มันเก็บเป็น s9e XML ที่ parse เสร็จแล้ว เวลาเรนเดอร์มันอ่านจาก XML ตรง ๆ ไม่ได้ parse ซ้ำ เพราะฉะนั้นถ้าปล่อย BBCode ดิบไว้ หน้าเว็บจะโชว์ [b]...[/b] เป็นตัวอักษรตามตัว
ขั้นตอนคือเปิด extension flarum/bbcode ก่อน แล้ว flush formatter cache ไม่งั้นตัว parser จะยังไม่รู้จักแท็ก BBCode จากนั้นไล่แปลงทุกโพสต์ผ่าน formatter ของ Flarum เอง เพื่อให้ได้ XML ที่ถูกต้องตาม pipeline ของมันจริง ๆ
<?php
// รันด้วย php cli ของ Flarum เอง (bootstrap แล้วจะมี $formatter ให้ใช้)
// เปิด flarum/bbcode ใน admin ก่อน แล้ว flush ไม่งั้น [b] ไม่ถูกตีความ
$formatter->flush();
$rows = DB::table('posts')->select('id', 'content')->orderBy('id')->cursor();
foreach ($rows as $p) {
// content ตอนนี้ยังเป็น BBCode ดิบ -> parse เป็น s9e XML ที่ Flarum เก็บจริง
$xml = $formatter->parse($p->content, null);
DB::table('posts')->where('id', $p->id)->update(['content' => $xml]);
}
สองแสนกว่าโพสต์ parse ทีเดียวกินเวลาและ RAM ถ้าหลุดกลางทางจะไม่รู้ว่าถึงไหน ผมแบ่งเป็น chunk แล้วเขียน id ตัวสุดท้ายลงไฟล์ทุกรอบ ล้มแล้วรันต่อจากจุดเดิมได้ — งานยาว ๆ ต้อง resume ได้เสมอ
attachment อยู่บนดิสก์ ไม่ได้อยู่ใน DB
อีกจุดที่หลายคนพลาด: vBulletin 4 ตั้งค่า attachfile = 2 หมายถึงเก็บไฟล์แนบไว้บน filesystem ไม่ใช่ BLOB ในฐานข้อมูล เพราะฉะนั้นถ้าไปไล่ดูตาราง filedata จะเห็นคอลัมน์ BLOB ว่างเปล่า แล้วนึกว่าไฟล์หาย — จริง ๆ ไฟล์อยู่บนดิสก์ตาม pattern ของมัน
# vBulletin attachfile = 2 = ไฟล์อยู่บนดิสก์ ตาราง filedata เก็บแต่ metadata
# path pattern: ./attachment/vbulletin/<hash>/<filedataid>.attach
find /home/vbforum/attachment -name '*.attach' | wc -l
# ปลายทาง Flarum + fof/upload:
# ตัวไฟล์ -> public/assets/files/
# record -> ตาราง fof_upload_files + fof_upload_file_posts (ผูกไฟล์กับโพสต์)
ไฟล์ .attach คือ byte ดิบของไฟล์จริง แค่ไม่มีนามสกุล ต้องไปอ่านนามสกุลจริงจากคอลัมน์ extension ในตาราง filedata แล้ว copy ออกมาตั้งชื่อใหม่ ก่อนจะลงทะเบียนเข้า fof/upload ให้ผูกกลับไปหาโพสต์ที่มันเคยแนบอยู่
รันสคริปต์บนเซิร์ฟเวอร์: อย่าใช้ heredoc
เรื่องสุดท้ายเป็นบทเรียนตอนรันจริง สคริปต์ migrate ของผมเขียนบนเครื่อง dev แล้วต้องไปรันบนเซิร์ฟเวอร์ ตอนแรกขี้เกียจ scp เลยส่ง PHP เข้าไปทาง SSH heredoc ตรง ๆ ปรากฏว่าโค้ดที่มี $row["userid"] พังหมด เพราะ shell ไปยุ่งกับ $ และ double-quote ข้างในก่อนที่ PHP จะได้เห็น กลายเป็นตัวแปรว่าง อ่าน error งงอยู่นาน
# อย่า: heredoc ทำ shell แปลง $var["key"] ก่อน PHP ได้เห็น -> ค่าเพี้ยน
ssh [email protected] php <<EOF
echo \$row["userid"]; // โดน shell กินตั้งแต่ยังไม่ถึง PHP
EOF
# ทำ: scp ไฟล์ขึ้นไปก่อน แล้วสั่งรันเป็นไฟล์ ให้ PHP อ่านซอร์สเองทั้งก้อน
scp migrate_posts.php [email protected]:/home/admin/
ssh [email protected] '/usr/local/php82/bin/php /home/admin/migrate_posts.php'
สคริปต์ที่ scp ขึ้นไปชั่วคราวกับไฟล์ dump ระหว่างทาง อย่าลืมลบทิ้งหลังจบงาน และในสคริปต์อย่า hardcode path แบบ Windows (d:\...) เข้าไป เพราะพอไปรันบน Linux มันจะกลายเป็นชื่อไฟล์แปลก ๆ ที่ตัวมอนิเตอร์ไฟล์ของเซิร์ฟเวอร์จับได้ — สั่งรันเป็นไฟล์ผ่าน stdin หรือ path แบบ Linux เท่านั้น
สรุปลำดับที่ผมใช้จริง
- map ตามลำดับ dependency: users -> tags -> discussions -> posts -> metadata อย่ายัดทีเดียว
- guest userid 0 -> NULL ด้วย NULLIF และปิด FOREIGN_KEY_CHECKS ระหว่าง bulk insert แล้วเปิดคืน + นับแถวเทียบ
- รหัสผ่าน: ยก hash + salt เดิมมาตรง ๆ ตั้ง old-passwords เป็น md5-double / salt-after ห้าม re-hash
- sticky/lock/approve ไม่มีใน core Flarum แล้ว -> flarum/sticky, flarum/lock, และ hidden_at (ซ่อนต้องแก้ทั้ง discussions และ posts)
- BBCode -> s9e XML ผ่าน formatter ของ Flarum เอง (เปิด flarum/bbcode + flush ก่อน) ทำเป็น batch ที่ resume ได้
- attachment attachfile=2 อยู่บนดิสก์ (.attach) ไม่ใช่ BLOB -> copy + ตั้งนามสกุลจาก filedata.extension -> ลง fof/upload
- รันบนเซิร์ฟเวอร์ scp ไฟล์แล้วสั่งรันเป็นไฟล์ อย่าใช้ heredoc และเก็บของชั่วคราวให้หมด
ส่วนตัวผมมองว่างานย้ายบอร์ดไม่ได้ยากที่ตัวข้อมูล แต่ยากตรงรายละเอียดเล็ก ๆ ที่ต่างกันระหว่างสอง schema นี่แหละ — จุดที่ทำ import ล้มมักไม่ใช่โพสต์สองแสนกว่า แต่เป็น userid 0 แถวเดียว หรือ salt ที่วางผิดตำแหน่ง ใครกำลังจะย้ายบอร์ดตัวเอง แนะนำให้ทำบน copy ของฐานข้อมูลก่อนเสมอ นับแถวเทียบสองฝั่งทุกชุด แล้วค่อยตัด over ตัวจริงตอนมั่นใจ ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



ความคิดเห็น 0
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย