- ระหว่างใช้งาน GitHub API พบปัญหาว่า ฟีเจอร์สร้างลิงก์คอมเมนต์ PR ใช้งานไม่ได้เนื่องจาก ID ไม่ตรงกัน
- จากการตรวจสอบพบว่า GitHub ใช้ ระบบ ID สองแบบควบคู่กัน คือ node ID ของ GraphQL และ database ID ของ REST API
- เมื่อลอง ถอดรหัส base64 ของ node ID พบว่า database ID ถูกรวมอยู่ใน 32 บิตล่าง ทำให้ แปลงค่าได้ด้วยการทำ bitmask แบบง่ายๆ
- จากการวิเคราะห์เพิ่มเติมยังพบว่า GitHub กำลังใช้ทั้ง ฟอร์แมต ID ใหม่ที่อิง MessagePack และ ฟอร์แมตเก่าแบบสตริง ปะปนกัน
- โครงสร้างนี้สะท้อนให้เห็นถึง ความเป็นคู่ของระบบระบุอ็อบเจ็กต์ภายใน GitHub และนักพัฒนาควรระวังเมื่อต้องเชื่อมต่อ API เข้าด้วยกัน
การค้นพบระบบ ID แบบคู่ของ GitHub
- ระหว่างพัฒนาฟีเจอร์ของ เครื่องมือรีวิวโค้ดด้วย AI ของ Greptile พบปัญหาว่าลิงก์คอมเมนต์ PR บน GitHub ใช้งานไม่ได้
- แม้จะนำ ID คอมเมนต์ที่บันทึกไว้ไปต่อใน URL แต่เมื่อคลิกแล้วกลับไม่พาไปยังหน้าของ GitHub
- เมื่อตรวจเอกสารของ GitHub จึงพบว่า node ID ของ GraphQL API และ database ID ของ REST API เป็นคนละระบบกัน
- ตัวอย่าง node ID:
PRRC_kwDOL4aMSs6Tkzl8
- ตัวอย่าง database ID:
2475899260
- node ID เป็น สตริงที่เข้ารหัสแบบ base64 สำหรับระบุอ็อบเจ็กต์แบบ global ทั่วทั้ง GitHub ส่วน database ID ถูกใช้เป็น ตัวระบุแบบจำนวนเต็มใน URL
การวิเคราะห์ความสัมพันธ์ระหว่าง node ID และ database ID
- เมื่อเปรียบเทียบ node ID และ database ID ของคอมเมนต์ PR หลายรายการ พบว่า ค่าทั้งสองเพิ่มขึ้นเป็นช่วงอย่างสม่ำเสมอ
- เมื่อนำส่วน base64 ของ node ID ไปถอดรหัส จะได้ จำนวนเต็มขนาด 96 บิต และพบว่า 32 บิตล่างของค่านี้ตรงกับ database ID
- ตัวอย่าง:
PRRC_kwDOL4aMSs6Tkzl8 → 32 บิตล่าง = 2475899260
- จึงสามารถดึง database ID ออกมาได้ด้วย การทำ bitmask แบบง่ายๆ
- แปลงค่าด้วยนิพจน์ลักษณะ
decoded & ((1 << 32) - 1)
ฟอร์แมต ID แบบเก่าของ GitHub
- เมื่อลองถอดรหัส node ID ของรีโพเก่า (
torvalds/linux) พบว่าได้ สตริงในรูปแบบที่ต่างออกไป
- ตัวอย่าง:
MDEwOlJlcG9zaXRvcnkyMzI1Mjk4 → 010:Repository2325298
- ฟอร์แมตนี้มีโครงสร้างเป็น
[หมายเลขชนิดอ็อบเจ็กต์]:[ชื่ออ็อบเจ็กต์][Database ID] ซึ่งเป็น ตัวระบุแบบสตริงที่ระบุข้อมูลอย่างชัดเจน
- ในกรณีของ tree object จะอยู่ในรูป
04:Tree2325298:7201bfb9... โดยมี ทั้ง repository ID และค่า SHA รวมอยู่ด้วย
- GitHub ใช้ ฟอร์แมตเก่าและฟอร์แมตใหม่ควบคู่กัน โดยรูปแบบจะแตกต่างกันตามชนิดของอ็อบเจ็กต์และช่วงเวลาที่สร้าง
โครงสร้างของฟอร์แมต node ID แบบใหม่
- แม้ คู่มือการย้ายระบบ GraphQL ของ GitHub จะระบุว่าให้มอง node ID เป็นสตริงทึบแสง แต่ภายในยังมีโครงสร้างอยู่
- หลังถอดรหัส base64 แล้วนำไป unpack ด้วย MessagePack จะได้ข้อมูลในรูปแบบอาร์เรย์
- ตัวอย่าง:
[0, 47954445, 2475899260]
- องค์ประกอบของอาร์เรย์
- องค์ประกอบแรก (0): คาดว่าเป็นตัวระบุเวอร์ชัน
- องค์ประกอบที่สอง (47954445): database ID ของรีโพ
- องค์ประกอบที่สาม (2475899260): database ID ของอ็อบเจ็กต์
- ความยาวของอาร์เรย์จะแตกต่างกันตามชนิดของอ็อบเจ็กต์ โดย commit จะมี SHA รวมอยู่ด้วย และ repository จะมีเพียงสององค์ประกอบ
การใช้งานจริงและข้อสรุป
1 ความคิดเห็น
ความเห็นจาก Hacker News
GitHub global node ID แบบล่าสุดสามารถบังคับให้ใช้งานได้ผ่านเฮดเดอร์
'X-Github-Next-Global-ID'ID ประกอบด้วย คำนำหน้าประเภทของอ็อบเจ็กต์ และเพย์โหลด msgpack ที่เข้ารหัสแบบ base64
ตัวอย่างเช่น user ID ของฉัน
"U_kgDOAAhEkg"ถอดรหัสได้เป็น[0, 541842]ซึ่งตรงกับdatabaseIdของ REST APIแต่ไม่ควรพึ่งพาการติดตั้งใช้งานภายในแบบนี้ และควรเรียกดูฟิลด์
databaseIdจาก GraphQL API โดยตรงจะดีกว่าเอกสารที่เกี่ยวข้อง: คู่มือย้าย GraphQL global node ID, ข้อมูลผู้ใช้ GitHub ของฉัน, ตัวอย่างการถอดรหัสด้วย CyberChef, การติดตั้งใช้งาน GitHub ETag
คิดว่าการถอดรหัสแบบนี้ เปราะบาง
โดยหลักแล้ว global node ID ของ GraphQL ควรเป็นแบบ opaque
หลาย type ของ GitHub (เช่น PullRequest) มีฟิลด์
databaseIdให้ใช้อยู่แล้ว ดังนั้นควรใช้ฟิลด์นั้นGraphQL API ส่วนใหญ่มักเข้ารหัสชื่อ type กับ DB ID ด้วย base64 แต่ไม่มีอะไรรับประกันได้ว่ากฎนี้จะคงอยู่ตลอดไป
ดูเพิ่มเติม: เอกสารอ็อบเจ็กต์ PullRequest, สเปก GraphQL global ID
permalink,urlและมีอินเทอร์เฟซUniformResourceLocatableอยู่แล้ว จึงไม่จำเป็นต้องประกอบ URL เองนั่นจึงเป็นเหตุผลที่ API ให้ permalink มา เพราะ ID หรือแพตเทิร์นของลิงก์สามารถเปลี่ยนได้ทุกเมื่อ
วิธีแบบนี้ก็พบได้บ่อยใน pagination token
ID อย่าง
010:Repository2325298มีโครงสร้างที่ชัดเจน010คือ enum ของ type,Repositoryคือชื่อ,2325298คือ DB IDกล่าวคือเป็นรูปแบบ length prefix นั่นเอง โดย Repository ยาว 10 ตัวอักษร ส่วน Tree ยาว 4 ตัวอักษร
Opus 4.5 รู้จักทริกการถอดรหัส GitHub ID แบบนี้ และสามารถเขียนโค้ดถอดรหัสให้อัตโนมัติได้
สิ่งที่ผู้เขียนค้นพบนั้นถูกต้องในเชิงเทคนิค แต่ ไม่ได้มีเอกสารรองรับและไม่ได้รองรับอย่างเป็นทางการ
ที่ผ่านม GitHub เคยเปลี่ยนโครงสร้างภายในของ node ID แบบเงียบ ๆ มาแล้ว
ถ้ามีการเพิ่มฟิลด์ในอาร์เรย์ MessagePack, เปลี่ยน encoding, เข้ารหัส, หรือเปลี่ยนไปใช้ UUID
ระบบที่พึ่งพาโครงสร้างภายในแบบนี้ก็จะพังทันที
ตัวระบุ GitHub ที่ฉันเก็บอย่างชัดเจนมีแค่ คีย์ URL ที่ไม่เปลี่ยนแปลง (เช่น หมายเลข issue/pr หรือ commit hash)
ส่วน comment ID ก็ใส่ไว้ใน JSON blob ตรง ๆ
ไม่จำเป็นต้องทำให้ทุกอย่างเป็น normalized เสมอไป JSON เร็วพออยู่แล้ว
ถ้าไม่ได้ทำ cross-query ในระดับ comment จริง ๆ ก็มักไม่ค่อยเจอปัญหาด้านประสิทธิภาพ
ถ้า repository เปลี่ยนชื่อหรือย้ายไปองค์กรอื่น URL ก็เปลี่ยนได้
ใน v3 API สมัยก่อนยังไม่มี ID ทำให้ถ้าใครเปลี่ยนชื่อผู้ใช้หรือชื่อ repository ก็จะติดตามได้ยากว่าเป็นใคร
เพราะแบบนั้นฉันจึงทำ ระบบจัดการ ownership ระดับทีม ขึ้นมาเอง
Terraform provider ก็ไม่ค่อยดีนัก จึงเจอปัญหาอย่าง “คนที่เป็นแอดมินเพียงคนเดียวลาออกไปแล้ว” บ่อยมากตอน offboarding
ทุก repository จะมีทีมเป็นเจ้าของ และให้สิทธิ์เข้าถึงในระดับทีมเท่านั้น
การควบคุมสิทธิ์การเข้าถึงแบบอิงทีม ลักษณะนี้มีประโยชน์ไม่ใช่แค่กับ GitHub แต่กับระบบอื่นด้วย
นี่เป็นกรณีตัวอย่างของ Hyrum’s Law — เมื่อผู้คนเริ่มพึ่งพาพฤติกรรมที่ไม่ได้มีการบันทึกไว้ สุดท้ายมันก็จะพัง
ในการออกแบบฐานข้อมูล มักจะให้ natural key แบบ opaque กับภายนอก และใช้ integer ID แบบเพิ่มขึ้นเรื่อย ๆ ภายใน
แต่ถ้าใช้ ID แบบผสม ปัญหาเหล่านี้จะลดลง
เช่น ถ้าใน repository ID มี object ID รวมอยู่ด้วย การไล่ค่า ID ก็จะสำรวจได้แค่อ็อบเจ็กต์ใน repository เดียวกัน
ถ้าผสม entropy หรือ timestamp เข้าไปด้วย ก็แทบจะนำไปใช้ในทางที่ผิดไม่ได้
ดังนั้นการเปิดเผย surrogate key ที่ไม่มีความหมายจึงปลอดภัยกว่า
ตัวอย่างเช่น YouTube ต่อให้ภายในจะใช้เลขดัชนี ก็ยังให้ ID ภายนอกเป็นรหัสที่ไม่มีความหมาย
ตอนนี้เริ่มเข้าใจแล้วว่าทำไมในช่วงไม่กี่ปีที่ผ่านมา ทีม GitHub ถึงขยายการรองรับ sharded/multi-database ใน Rails อย่างมาก