1 คะแนน โดย GN⁺ 2 시간 전 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • Mercury ให้บริการธนาคารแก่ธุรกิจกว่า 300,000 แห่งด้วยโค้ดเบส Haskell ราว 2 ล้านบรรทัด ไม่นับคอมเมนต์ และในปี 2025 รองรับปริมาณธุรกรรม 248 พันล้านดอลลาร์และรายได้ต่อปีแบบ annualized 650 ล้านดอลลาร์
  • คุณค่าของการใช้ Haskell ที่ Mercury ไม่ได้อยู่ที่ความบริสุทธิ์เพียงอย่างเดียว แต่คือการใส่ ความรู้ด้านการปฏิบัติการ ไว้ใน API และ type, ซ่อนพฤติกรรมที่เสี่ยงไว้หลังขอบเขตที่แคบ และทำให้เส้นทางที่ปลอดภัยเป็นเส้นทางที่ง่ายที่สุด
  • ความน่าเชื่อถือไม่ได้ถูกมองว่าเป็นการป้องกันความล้มเหลวทั้งหมด แต่เป็นความสามารถของระบบในการ ดูดซับความผันผวน โดย type system ช่วยตัดข้อผิดพลาดบางประเภทออกไป และคงความรู้เชิงสถาบันไว้ในรูปเอกสารที่ compiler บังคับใช้
  • Mercury ใช้ Temporal เป็นเฟรมเวิร์ก durable execution สำหรับงานลองใหม่, timeout, การยกเลิก และการกู้คืนจาก crash ในเวิร์กโฟลว์การเงิน พร้อมเปิดซอร์ส Haskell SDK hs-temporal-sdk
  • คุณค่าของ Haskell ในงานโปรดักชันไม่ได้อยู่ที่การใส่ทุกอย่างลงใน type แต่คือการปกป้อง invariant ที่นำไปสู่ข้อมูลสูญหาย, ความผิดพลาดทางการเงิน, และปัญหาด้านกำกับดูแลด้วย type ขณะเดียวกันก็ ห่อหุ้มความซับซ้อน และใช้งานร่วมกับการทดสอบ, เอกสาร, และ code review

ขนาดการใช้งาน Haskell ของ Mercury และมุมมองเรื่องความน่าเชื่อถือ

  • Mercury ดูแลโค้ดเบส Haskell ขนาดประมาณ 2 ล้านบรรทัด ไม่นับคอมเมนต์
  • Mercury เป็นบริษัทฟินเทคที่ให้บริการธนาคารแก่ธุรกิจกว่า 300,000 แห่ง และในปี 2025 รองรับปริมาณธุรกรรม 248 พันล้านดอลลาร์ กับรายได้ต่อปีแบบ annualized 650 ล้านดอลลาร์
  • บริษัทมีพนักงานราว 1,500 คน และองค์กรวิศวกรรมส่วนใหญ่รับคนที่เป็นนักพัฒนาทั่วไป โดยส่วนมากไม่เคยใช้ Haskell มาก่อนเข้าทำงาน
  • ระบบนี้ทำงานมาเป็นเวลาหลายปีผ่านทั้งการเติบโตอย่างรวดเร็ว, เหตุการณ์วิกฤต SVB ที่มีเงินฝากใหม่ไหลเข้า 2 พันล้านดอลลาร์ ภายใน 5 วัน, การตรวจสอบด้านกำกับดูแล, และทั้งสถานการณ์ปกติและไม่ปกติของระบบการเงินขนาดใหญ่

ความน่าเชื่อถือไม่ใช่การป้องกันความล้มเหลว แต่คือความสามารถในการดูดซับความผันผวน

  • แนวทางความน่าเชื่อถือแบบดั้งเดิมมักเน้นการไล่รายการความล้มเหลว, เพิ่มการตรวจสอบและการทดสอบ, และค้นหาบั๊ก แต่เพียงเท่านี้ยังไม่พอ
  • Mercury มองความน่าเชื่อถือว่าเป็นความสามารถของระบบในการ ดูดซับความผันผวน
    • ระบบต้องเสื่อมประสิทธิภาพได้อย่างนุ่มนวล
    • ผู้ปฏิบัติการต้องสามารถเข้าใจและปรับจูนระบบได้
    • สถาปัตยกรรมต้องทำให้สิ่งที่ถูกต้องเป็นเรื่องง่าย และสิ่งที่ผิดทำได้ยาก
  • ในองค์กรที่เติบโตเร็ว คำถามเชิงปฏิบัติการจริงคือ วิศวกรที่เพิ่งเข้ามาใหม่สามารถอ่านและเข้าใจโมดูลได้หรือไม่, เมื่อฐานข้อมูลช้าบริการจะล้มไปพร้อมกันหรือไม่, และ compiler จะจับการใช้ interface ผิดวิธีได้หรือไม่
  • type system มีลักษณะใกล้เคียงกับ เครื่องมือช่วยปฏิบัติการ มากกว่าการพิสูจน์ความถูกต้องอย่างเป็นนามธรรม
    • มันตัดข้อผิดพลาดบางประเภทออกไป
    • มันทิ้งความรู้เชิงสถาบันไว้ในรูปแบบที่ compiler อ่านได้ แม้ผู้เขียนจะจากไปแล้ว
    • มันทำหน้าที่เป็นเอกสารที่ถูกบังคับใช้อย่างสม่ำเสมอกว่าวิกิ
  • วิศวกรรมเสถียรภาพของ Mercury ไม่ใช่ตำรวจคุณภาพที่ทำให้การพัฒนาผลิตภัณฑ์ช้าลง แต่เป็นรูปแบบการทำงานร่วมกันที่จัดการผลกระทบเมื่อฟีเจอร์พัง ตั้งแต่ช่วงต้นของการออกแบบ
    • ขอบเขตผลกระทบเมื่อเกิดความล้มเหลว
    • งานใดต้องการ idempotency และต้องทำอย่างไร
    • รูปแบบการ rollback
    • การจัดการงานที่กำลังดำเนินอยู่
    • พิจารณาล่วงหน้าว่าระบบใดดูดซับความล้มเหลว และระบบใดขยายมันให้รุนแรงขึ้น

ความบริสุทธิ์ไม่ใช่คุณสมบัติของภาษา แต่เป็นขอบเขตของ interface

  • ความบริสุทธิ์ของ Haskell ไม่ได้หมายความว่าภายในไม่มี side effect เลย แต่ใกล้เคียงกับการที่ interface สร้างขอบเขตที่ป้องกันการรั่วไหลของ side effect มากกว่า
  • เบื้องหลังฟังก์ชันบริสุทธิ์ของไลบรารีอย่าง bytestring, text, vector มี implementation ภายในที่รวมถึง mutable allocation, การเขียนบัฟเฟอร์, และ unsafe coercion
  • ST monad ใช้การเปลี่ยนแปลงแบบ in-place และ side effect ที่สังเกตได้ภายใน computation แต่ rank-2 type ของ runST ป้องกันไม่ให้ mutable reference ที่สร้างขึ้นภายในหลุดออกมา
    runST :: (forall s. ST s a) -> a  
    
  • ภายในสามารถมีพฤติกรรมแบบ imperative ได้ แต่ภายนอกจะเห็นเพียงผลลัพธ์ และสถานะที่เปลี่ยนแปลงได้จะไม่รั่วออกนอกขอบเขต
  • หลักการนี้ถูกนำไปใช้กับระบบปฏิบัติการโดยรวม
    • ชั้นฐานข้อมูลอาจใช้ connection pooling, การลองใหม่, และ mutable state ภายใน
    • cache อาจใช้ concurrent mutable map
    • HTTP client อาจมี circuit breaker, connection pool, และงาน bookkeeping จำนวนมาก
    • แก่นสำคัญคือการห่อการทำงานที่เสี่ยงไว้ด้วย interface ที่แคบ เพื่อให้การใช้ผิดวิธีทำได้ยาก
  • ในระบบจริง เป้าหมายไม่ใช่การหลีกเลี่ยงการเปลี่ยนแปลงทั้งหมด แต่คือการทำให้ชัดเจนว่าการเปลี่ยนแปลงอยู่ตรงไหน และจำกัดว่าใครบ้างในโค้ดเบสที่จำเป็นต้องรู้เรื่องนั้น

ทำให้สิ่งที่ถูกต้องเป็นสิ่งที่ทำได้ง่าย

  • ในโค้ดเบสขนาดใหญ่ มักเกิดแพตเทิร์นที่ความถูกต้องขึ้นอยู่กับลำดับบางอย่างหรือขั้นตอนเพิ่มเติมที่มองไม่เห็น
    • ต้อง flush audit log หลัง transaction
    • ต้องตรวจสอบ feature flag ก่อนเรียก endpoint
    • ต้อง enqueue การแจ้งเตือนภายใน database transaction
  • หากความรู้เชิงปฏิบัติการเหล่านี้มีอยู่แค่ในวิกิ, เอกสาร onboarding, design review เก่า ๆ, เธรดใน Slack, หรือความทรงจำของวิศวกรอาวุโสบางคน มันก็จะหายไปอย่างรวดเร็ว
  • Haskell สามารถ เข้ารหัสขั้นตอนเหล่านี้ไว้ใน type เพื่อทำให้ลืมไม่ได้
  • วิธีที่ไม่ดีคือการขอร้องให้ใช้ฟังก์ชันที่ถูกต้อง แต่ยังปล่อยทางลัดไว้
    -- Please use this one, not the other one  
    writeWithEvents :: Transaction -> [Event] -> IO ()  
    
    -- Don't use this directly (but we can't stop you)  
    writeTransaction :: Transaction -> IO ()  
    publishEvents :: [Event] -> IO ()  
    
    • วิธีที่ดีกว่าคือปรับโครงสร้าง type เพื่อให้เส้นทางเดียวที่ใช้รันงานนั้นรวมการ publish event อยู่ด้วย
    data Transact a -- opaque; cannot be run directly  
    record :: Transaction -> Transact ()  
    emit :: Event -> Transact ()  
    
    -- The *only* way to execute a Transact: commit and publish atomically  
    commit :: Transact a -> IO a  
    
  • ในที่นี้ type system ไม่ได้มีไว้เพื่อพิสูจน์ทฤษฎีลึกซึ้งเกี่ยวกับ event แต่เพื่อทำให้ขั้นตอนปฏิบัติการที่ถูกต้องเป็น เส้นทางที่ง่ายที่สุด
  • เมื่อวิศวกรใหม่ถามว่าจะเขียน transaction อย่างไร type signature และ API ที่เปิดเผยอยู่ก็ให้คำตอบได้ และแม้วิศวกรอาวุโสจะออกไปแล้ว ความรู้ก็ยังคงอยู่

การทำงานแบบคงทนและ Temporal

  • เวิร์กโฟลว์ของระบบการเงินไม่ได้จบอยู่ภายในธุรกรรมเดียว
    • ส่งการชำระเงิน
    • รอการอนุมัติจากพาร์ตเนอร์
    • อัปเดตบัญชีแยกประเภท
    • แจ้งลูกค้า
    • จัดการการยกเลิกและการหมดเวลา
    • กรณีพาร์ตเนอร์สำเร็จแล้วแต่ worker ตายก่อนบันทึก
    • กรณีไม่มีการตอบกลับเนื่องจากปัญหาเครือข่าย
  • เวิร์กโฟลว์เหล่านี้ต้องการ state, retry, timeout, idempotency และการทำงานที่คงอยู่ต่อเนื่องข้าม process crash และการ deploy
  • ในอดีต Mercury ประสานกระบวนการเหล่านี้ด้วย state machine ที่อิงฐานข้อมูล, งาน cron, background worker รวมถึงการจัดการ retry และ timeout ที่กระจายอยู่ทั่วโค้ด
    • มันใช้งานได้ แต่เปราะบาง เข้าใจยาก และเป็นสาเหตุของ incident ฝั่งปฏิบัติการอย่างไม่สมส่วน
  • Temporal คือเฟรมเวิร์ก durable execution ของ Mercury ซึ่งทำให้เขียน workflow ได้เหมือนโค้ดลำดับทั่วไป และแพลตฟอร์มจะบันทึกแต่ละขั้นลงใน event history
  • หาก worker crash กลาง workflow worker ตัวอื่นจะ replay prefix ที่เป็น deterministic เพื่อสร้าง state กลับขึ้นมาใหม่ และทำต่อจากจุดที่หยุดไว้
  • แทนที่แต่ละทีมจะต้องทำ retry, timeout, cancellation และ error handling ขึ้นมาเอง แพลตฟอร์มเป็นผู้จัดเตรียมสิ่งเหล่านี้ให้
  • Temporal workflow มีลักษณะคล้าย pure function ที่ทำงานกับ event history
    • workflow ที่ถูก replay ต้องสร้างลำดับคำสั่งแบบเดียวกับต้นฉบับ
    • ข้อกำหนดเรื่องความเป็น deterministic นี้คล้ายกับข้อจำกัด same input, same output ของ pure code
    • side effect ถูกแยกออกไปอยู่ใน activity ซึ่งเทียบได้กับ IO ของ workflow
  • Mercury ได้สร้าง Haskell SDK hs-temporal-sdk ที่ครอบ Core SDK อย่างเป็นทางการของ Temporal ผ่าน Rust FFI และเปิดซอร์สให้ใช้งาน
  • รูปแบบการนำ Temporal มาใช้ยังถูกกล่าวถึงใน Temporal Replay conference presentation และ Mercury ก็ได้รับการปรับปรุงด้านปฏิบัติการจากการแทนที่โซ่ cron และ state machine ที่เปราะบางด้วย durable workflow

ออกแบบโดเมนด้วยภาษาธุรกิจ ไม่ใช่ชั้นการรับส่งข้อมูล

  • ข้อผิดพลาดที่พบบ่อยในระบบที่เติบโตแล้วคือแนวคิดของระบบที่ถูกเรียกไหลรั่วเข้ามาใน domain model
  • เมื่อโค้ดที่เขียนไว้สำหรับ HTTP request handler ถูกนำกลับไปใช้ภายหลังในงาน cron, queue-based background worker และ Temporal workflow ก็อาจเกิดกรณีที่ exception แบบ HTTP อย่าง StatusCodeException 409 "Conflict" แพร่ไปยังบริบทที่ไม่ใช่ HTTP
  • สำหรับงาน cron ไม่มีผู้เรียกที่กำลังรอรับ 409 response อยู่ และ status code ก็ดึงความหมายทางธุรกิจไปไว้ในชั้นที่ผิด
  • วิธีแก้คือโมเดล domain error ให้เป็น type ของโดเมน
    • ยอดเงินไม่พอควรเป็น InsufficientFunds
    • คำขอซ้ำควรเป็น DuplicateRequest
    • พาร์ตเนอร์หมดเวลาควรเป็น PartnerTimeout
  • ที่แต่ละ boundary ให้มีชั้นแปลงบาง ๆ
    data PaymentError  
      = InsufficientFunds  
      | DuplicateRequest RequestId  
      | PartnerTimeout Partner  
    
    toHttpError :: PaymentError -> HttpResponse  
    toHttpError InsufficientFunds       = err402 "Insufficient funds"  
    toHttpError (DuplicateRequest _)    = err409 "Duplicate request"  
    toHttpError (PartnerTimeout _)      = err502 "Partner unavailable"  
    
    toWorkerStrategy :: PaymentError -> WorkerAction  
    toWorkerStrategy InsufficientFunds    = Fail "Insufficient funds"  
    toWorkerStrategy (DuplicateRequest _) = Skip  
    toWorkerStrategy (PartnerTimeout _)   = RetryWithBackoff  
    
  • ประเด็นของชั้น transport ควรอยู่ที่ขอบระบบ และ domain model ไม่ควรต้องพก HTTP status code ติดตัวไป ไม่ว่าจะถูกเรียกจาก web handler, CLI, งาน cron, background worker หรือ workflow engine ก็ตาม

ต้นทุนของการเข้ารหัสด้วย type และระดับที่เหมาะสม

  • การใส่ invariant ลงไปใน type เป็นสิ่งที่ทรงพลัง แต่ก็มาพร้อมต้นทุนด้าน ภาระทางการรับรู้, ความแข็งตัว และความยากเมื่อข้อกำหนดเปลี่ยน
  • ถ้าการละเมิดนำไปสู่ข้อมูลเสียหาย, ความผิดพลาดทางการเงิน, ปัญหาด้านกฎระเบียบ หรือ incident จากการต้องรอคนมารับช่วง ต้นทุนของการเข้ารหัสด้วย type ก็ถือว่าคุ้มค่า
  • แต่ถ้าเป็นเพียงเพราะปัจจุบันทำกันแบบนั้น หรือเพราะอยากลองใช้เทคนิคระดับ type ก็มีโอกาสสูงที่จะทำให้ codebase เปลี่ยนแปลงได้ยาก
  • ฝั่งที่เข้ารหัสมากเกินไป

    • สถานะที่ไม่ถูกต้องไม่สามารถแทนค่าได้ และโดเมนถูกจำลองด้วย type อย่างซื่อตรง
    • การเปลี่ยนกฎธุรกิจนำไปสู่การเปลี่ยน type ที่กระทบข้าม 50 โมดูล ทำให้การ refactor ยืดเยื้อ
    • วิศวกรใหม่เข้าใจ type signature ได้ยากขึ้น
  • ฝั่งที่ไม่เข้ารหัสอะไรเลย

    • type เข้าใกล้ String, IO () หรือแย่ที่สุดคือ Dynamic
    • โค้ดเปลี่ยนง่าย แต่ไม่มีสัญญากำกับ และความหมายขึ้นอยู่กับความทรงจำของผู้เขียนเดิม
    • เมื่อผู้เขียนออกไปแล้ว ก็ยากจะรู้ว่าทำไมระบบถึงไม่ทำงาน
  • เกณฑ์ที่มีประโยชน์

    • invariant ที่ช่วยป้องกัน ความเสียหายแบบเงียบ ๆ ควรใส่ไว้ใน type
      • ธุรกรรมที่ commit แล้วโดยไม่มี event
      • การชำระเงินที่ถูกประมวลผลโดยไม่มี audit log
      • การเปลี่ยนสถานะที่ดูเหมือนเป็นไปได้ แต่ในเชิงความหมายเป็นไปไม่ได้
    • invariant ที่จะ ล้มเหลวแบบชัดเจน อาจเพียงพอด้วย runtime check ที่มีข้อความ error ที่ดี
      • 500 response
      • assertion ล้มเหลว
      • type mismatch ที่ boundary ของ JSON
    • ควรยับยั้งแรงกระตุ้นที่จะพยายามจำลองทั้งโดเมนด้วย type
      • ในโดเมนมีข้อยกเว้น, กฎเพื่อรองรับความเข้ากันได้ย้อนหลัง, กฎที่ขัดกันเอง และพฤติกรรมพิเศษสำหรับลูกค้าบางราย
    • type เป็นเครื่องมือเพื่อทีม ไม่ใช่เพื่อ compiler อย่างเดียว
      • มันควรประกอบกันเป็นชั้นป้องกันร่วมกับการทดสอบ, เอกสาร, code review, ตัวอย่าง และ playbook
    • ภายใน Mercury มีไลบรารีที่ใช้กลไกระดับ type ที่ซับซ้อน เช่น GADT, type family และ phantom type ที่ติดตาม state transition
    • ความซับซ้อนแบบนี้จำเป็นสำหรับกลไกที่ถ้าพลาดแล้วจะทำให้เงินถูกย้ายผิดหรือ invariant ด้านกฎระเบียบพัง
    • หัวใจสำคัญคือการ ห่อหุ้ม ความซับซ้อน
    • โมดูลที่ทำ type-level state machine ควรมีผู้เขียนเพียงไม่กี่คนที่เข้าใจอย่างลึกซึ้ง และมีการทดสอบเพียงพอ
    • API ฝั่งผู้ใช้งานควรดูเป็นเพียงไม่กี่ฟังก์ชันที่มี type ธรรมดา
    • product engineer ควรเรียกใช้อย่างปลอดภัยได้โดยไม่ต้องรู้กลไกการพิสูจน์ระดับ type ที่อยู่ภายใน
    • หากในการ code review PR ที่แตะโมดูลอื่นเต็มไปด้วย type annotation ที่คัดลอกมาเพื่อเอาใจ compiler นั่นคือสัญญาณว่า abstraction กำลังรั่วข้ามขอบเขต

การออกแบบเพื่อให้ตรวจสอบภายในได้

  • หากความน่าเชื่อถือคือความสามารถในการปรับตัว การตรวจสอบภายในได้ ก็เป็นหนึ่งในวิธีที่จะได้มาซึ่งความสามารถนั้น
  • ผู้ปฏิบัติการไม่สามารถดูแลสิ่งที่มองไม่เห็นได้ และทีมก็ปรับตัวกับระบบที่ภายในไม่โปร่งใสได้ยาก
  • Haskell ไม่มี monkey patching จึงยากที่จะสลับ HTTP client ภายในไลบรารีระหว่างรันไทม์ หรือแทนที่การเรียกฐานข้อมูลด้วยฟังก์ชันที่สร้าง OpenTelemetry span
  • Rust ก็มีข้อจำกัดแบบเดียวกัน แต่ ecosystem ของ Rust ค่อย ๆ มาบรรจบที่แพตเทิร์น tower middleware ขณะที่ ecosystem ของ Haskell ยังแยกเป็นหลายแนวทาง
  • หากไลบรารีเปิดเผยเพียงชุดของฟังก์ชันระดับบนสุดแบบเจาะจง การทำ instrumentation จะต้องห่อด้วยโมดูลใหม่ และหวังให้ผู้คน import โมดูลนั้นแทนโมดูลเดิม
  • เรคคอร์ดของฟังก์ชัน

    • วิธีแก้ที่ใช้บ่อยที่สุดคือเปิดเผย เรคคอร์ดของฟังก์ชัน แทนฟังก์ชันแบบรูปธรรม
      -- A concrete module gives you no leverage:  
      sendRequest :: Request -> IO Response  
      -- A record of functions gives you all of it:  
      data HttpClient = HttpClient  
      { sendRequest :: Request -> IO Response  
      , getManager  :: IO Manager  
      }  
      
    • ด้วยวิธีนี้ คุณสามารถห่อ sendRequest ด้วยการวัดเวลาแล้วคืนค่า HttpClient ตัวใหม่ได้
    • สามารถเพิ่ม cross-cutting concern ระหว่างรันไทม์ได้ เช่น fault injection สำหรับการทดสอบ, การสลับ mock, retry, tracing, การ rewrite คำขอ, หรือพฤติกรรมราย tenant
    • แพตเทิร์นที่ทำให้การแปลงพฤติกรรมประกอบกันได้ เช่น type Middleware = Application -> Application ของ WAI มีประโยชน์มากในเชิงปฏิบัติการ
  • interceptor ที่ประกอบกันด้วย Monoid

    • โดยทั่วไปแล้ว middleware และชนิดของ interceptor สามารถมีอินสแตนซ์ Semigroup และ Monoid ได้
    • Middleware ของ WAI เป็น endomorphism และ endomorphism จะสร้าง monoid ภายใต้การ composition และ id
    • hook record ของ interceptor สามารถประกอบกันเป็นรายฟิลด์ได้ จึงรวม concern อย่าง tracing, timeout, และ task queue rewrite เข้าด้วยกันด้วย mconcat โดยไม่ต้องเดินท่อเพิ่มเติม
      appTemporalInterceptors =  
      mconcat  
        [ retargetingInterceptor  
        , otelInterceptor  
        , sentryInterceptor  
        , sqlApplicationNameInterceptor  
        , loggingContextInterceptor  
        , statementTimeoutInterceptor  
        , teamNameInterceptor  
        , clientExceptionInterceptor  
        , workflowTypeNameInterceptor  
        ]  
      
    • interceptor แต่ละตัวอยู่ในโมดูลอิสระและดูแล concern เพียงอย่างเดียว, override เฉพาะฟิลด์ที่ต้องการจาก mempty, และลำดับถูกระบุไว้อย่างชัดเจนในลิสต์
  • ระบบ effect

    • effect system อย่าง effectful, polysemy, fused-effects, cleff ก็เป็นอีกเส้นทางหนึ่งเช่นกัน
    • คุณสามารถนิยามการดำเนินการที่ใช้ได้เป็นชนิด effect และสลับ interpreter สำหรับ production, testing หรือ tracing ได้ที่จุดเรียกใช้
    • สามารถดัก effect เพื่อบันทึก metric หรือใส่ความหน่วง แล้วค่อยส่งต่อกลับไปยัง handler จริง
    • ข้อเสียคือมีชั้นกลไกเพิ่มเติมเข้ามา เช่น effect list ระดับ type, handler stack และข้อผิดพลาด type ที่จัดการยาก
    • เรคคอร์ดของฟังก์ชันเรียบง่ายพอที่วิศวกรใหม่จะเข้าใจได้ภายในบ่ายเดียว
  • ตัวอย่างเชิงบวกของ persistent

    • SqlBackend ของ persistent เป็นเรคคอร์ดของฟังก์ชัน เช่น connPrepare, connInsertSql, connBegin, connCommit, connRollback
    • เมื่อต้องเพิ่ม instrumentation ของ OpenTelemetry ก็สามารถห่อฟิลด์ที่เกี่ยวข้องเพื่อแนบ tracing span ให้กับงานฐานข้อมูลทั้งหมดได้
    • ทำให้มองเห็นชั้นฐานข้อมูลได้โดยไม่ต้อง fork และแทบไม่ต้องแก้ซอร์สเลย
  • ไลบรารีที่ใช้งานจริงได้ยาก

    • Mercury แทบไม่ใช้ binding ของ web API client ที่เผยแพร่บน Hackage
    • หาก binding จากภายนอกทำการเรียก HTTP ด้วยฟังก์ชันแบบรูปธรรม ก็จะยากต่อการทำ tracing, timeout ให้ตรง SLO, การจำลองปัญหาจากพาร์ตเนอร์ หรืออธิบายช่องว่าง 400ms ใน trace
    • เพราะเช่นนั้นจึงเขียน client เองและทำให้สังเกตการณ์ได้ตั้งแต่ต้น
  • ต้นทุนของ ecosystem ขนาดเล็ก

    • ไลบรารี Haskell บางตัวไม่ได้ถูกทอดทิ้ง แต่ยังคงอยู่ในลักษณะโครงสร้างพื้นฐานสาธารณะที่ไม่มีผู้รับผิดชอบชัดเจนและปรับปรุงอย่างรวดเร็ว
    • อินเทอร์เฟซเก่ายังคงอยู่ และการรับเอาการออกแบบใหม่ด้าน observability, การออกแบบขอบเขต และความพร้อมใช้งานเชิงปฏิบัติการอาจเป็นไปอย่างช้า ๆ
    • http-client รองรับโดยตรงเพียง HTTP/1.1 ซึ่งใช้งานได้ดีพอสมควร แต่ในบางช่วงเวลาก็อาจต้องอ้อมทางแก้ปัญหา

ข้อกำหนดเชิงปฏิบัติการสำหรับผู้เขียนแพ็กเกจ

  • ผู้เขียนไลบรารีควรจัดให้มี ทางหนีทีไล่ เช่น เรคคอร์ดของฟังก์ชัน, ชนิด effect หรือ callback เพื่อให้ผู้ใช้ฉีดพฤติกรรมเข้าไปได้โดยไม่ต้องแก้ซอร์ส
  • เพียงเพิ่ม hs-opentelemetry-api เป็น dependency และวาง span รอบงาน IO สำคัญ ก็ช่วยผู้ใช้ที่ต้องดูแลไลบรารีนั้นใน production ได้แล้ว
    • แพ็กเกจ API ค่อนข้างระมัดระวังต่อ breaking change และถูกออกแบบให้ทำงานแบบ inert หากแอปพลิเคชันไม่ได้เริ่มต้น OpenTelemetry SDK
    • มี performance overhead ต่ำมาก และจะไม่ก่อให้เกิด exception หรือ logging ที่ไม่คาดคิดจากแอปพลิเคชันผู้ใช้
    • footprint ของ dependency ยังไม่เล็กเท่าที่ต้องการนัก และกำลังอยู่ระหว่างการปรับปรุง
  • ไม่ควรเขียนล็อกโดยตรงจากโค้ดไลบรารี
    • แทนที่จะ import logging framework แล้วเขียนลง stdout หรือ stderr โดยตรง ควรมี callback, พารามิเตอร์ logger หรือชนิดข้อมูลข้อความล็อกที่ผู้เรียกสามารถกำหนดเส้นทางได้เอง
    • การตัดสินใจว่าล็อกจะไปที่ใดเป็นเรื่องของสภาพแวดล้อมการปฏิบัติการของแอปพลิเคชัน
    • Mercury ส่ง pipeline ของ structured log ไปยัง observability stack และหากไลบรารีเขียนลง stderr โดยตรง ก็จะต้องมีท่อแยกต่างหากจากสตรีม JSON lines
  • อาจพิจารณาเปิดเผยโมดูล .Internal ด้วย
    • ความกังวลว่าผู้ใช้อาจพึ่งพา API ภายในจนทำให้ refactor ยากขึ้นนั้นเป็นเรื่องสมเหตุสมผล
    • แต่ความมั่นใจว่า public API ได้รองรับทุก use case แล้วนั้น แทบไม่ค่อยมีเหตุผลรองรับจริง
    • โมดูล .Internal ที่มีคำเตือนเรื่องเสถียรภาพอย่างชัดเจนอาจดีกว่าการให้ผู้ใช้ไป fork และ vendoring แพ็กเกจเอง
    • containers, text, unordered-containers เป็นตัวอย่างที่ดีของแนวทางนี้ใน ecosystem ของ Haskell
    • อย่างไรก็ตาม หากผู้ใช้แก้ปัญหาที่ต้องการได้เงียบ ๆ ด้วยการใช้โมดูลภายใน feedback เกี่ยวกับข้อบกพร่องของ public API ก็อาจลดลงได้

สิ่งที่ไม่ใส่ไว้ใน type

  • แม้แต่ Haskell สำหรับ production ก็มีส่วนที่ไม่สวยงามอยู่
  • มีการใช้ unsafePerformIO ภายในไลบรารีที่เราใช้งานเป็นประจำ
    • bytestring และ text จัดสรรบัฟเฟอร์แบบ mutable ภายใน เขียนข้อมูลลงไป แล้ว freeze เพื่อสร้างผลลัพธ์
    • type ไม่ได้บอกว่าเกิดอะไรขึ้นบ้างระหว่างการสร้าง
    • ขอบเขตถูกคงไว้ด้วยธรรมเนียม, การ reasoning อย่างระมัดระวัง, และ code review
  • ถ้าทางเลือกที่ type-safe ทำให้ต้นทุนด้านประสิทธิภาพหรือความซับซ้อนสูงเกินไป คุณก็อาจต้องเขียนการประนีประนอมแบบนี้เอง
    • ควรบันทึก invariant ที่ type ไม่ได้ตรวจสอบไว้เป็นเอกสาร
    • ควรคงความไม่สะดวกนี้ไว้ และกลับมาตรวจสอบเป็นระยะว่าทางเลือกที่ type-safe กลายเป็นทางปฏิบัติได้แล้วหรือยัง
    • production Haskell ไม่ใช่การไม่มีการประนีประนอม แต่คือการ แยกการประนีประนอมอย่างมีวินัย
  • ไลบรารี Haskell จำนวนมากบน Hackage มีการทดสอบน้อยหรือไม่มีเลย
    • แนวคิดแบบ “ถ้าคอมไพล์ได้ก็แปลว่าใช้ได้” อาจจริงได้เป็นบางครั้งกับโค้ด pure ขนาดเล็กและ type ที่แข็งแรง
    • แต่แทบไม่จริงเลยกับโค้ดที่พึ่งพา IO หนัก ๆ, การเชื่อมต่อกับระบบภายนอก, หรือโค้ดที่บั๊กอยู่ที่ความหมายมากกว่าโครงสร้าง
  • type อาจบอกได้ว่าโค้ดคืนค่า Either ParseError Transaction แต่บอกสิ่งต่อไปนี้ไม่ได้
    • ฟิลด์ amount ถูก parse เป็นหน่วยเซ็นต์หรือดอลลาร์
    • partner API ตีความฟิลด์ที่ถูกละไว้กับฟิลด์ null ต่างกันหรือไม่
    • logic การ retry จะทำให้เกิดการคิดเงินซ้ำในช่วงเวลาพิเศษของปีอธิกสุรทินหรือไม่
  • ใน production เราสร้างระบบบนไลบรารีเหล่านี้ และรับช่วงสมมติฐานที่ยังไม่ถูกพิสูจน์มาด้วย จึงต้องอุดช่องด้วย integration test ของเลเยอร์ตัวเอง
  • การประนีประนอมอย่าง orphan instance, partial function ที่เชื่อว่าตามบริบทแล้ว total, error ที่สัญญาว่าไปไม่ถึง, FFI wrapper ที่ขัด ๆ, และ exception hierarchy ที่ทำด้วยมือ ก็สะสมขึ้นเรื่อย ๆ
  • เป้าหมายไม่ใช่ความบริสุทธิ์ทางศีลธรรม แต่คือการทำให้รู้ได้ผ่าน code review, เอกสาร, ตัวอย่าง, และการทดสอบ ว่าการประนีประนอมแต่ละจุดอยู่ตรงไหน เกิดขึ้นเพราะอะไร และถ้าเอาออกแล้วอะไรจะพัง

ทำไม Haskell จึงคุ้มค่าสำหรับใช้ใน production

  • Haskell ไม่ใช่ตัวเลือกที่เร็วตั้งแต่วันแรก
    • ecosystem ปัจจุบันยังไม่สามารถให้สภาพแวดล้อมพัฒนาแบบ batteries-included และ hot-reloading ได้ทันทีเหมือน Next.js หรือ Rails
    • ไลบรารีที่ต้องการอาจไม่มี หรือถึงมีก็อาจมีคนเดียวดูแลในเวลาว่าง
    • บางครั้งข้อความ error ก็เข้าใจยากมาก
  • ปัญหาเรื่องการจ้างงานถูกพูดเกินจริง
    • Max Tagher, CTO ของ Mercury เคยพูดต่อสาธารณะว่า backend Haskell engineer เป็นตำแหน่งที่จ้างได้ง่ายที่สุดในทั้งบริษัท Mercury
    • ความต้องการงาน Haskell มีมากกว่าอุปทาน จนพลวัตการจ้างงานแบบปกติกลับด้าน
    • Mercury จ้างทั้งคนที่มีประสบการณ์ Haskell ลึกมากและคนที่ไม่มีเลย โดยแบบหลังจะถูกทำให้พร้อมทำงานได้ผ่านโปรแกรมฝึกอบรม 6–8 สัปดาห์
    • ถ้าพรุ่งนี้คุณต้องการผู้เชี่ยวชาญ Haskell 100 คน ปัญหาเรื่อง talent pool ก็เป็นเรื่องจริง แต่ถ้าคุณพร้อมจะรับนักพัฒนาทั่วไปที่เก่งแล้วมาสอนต่อ ปัญหานี้ก็จริงน้อยลง
  • ความเสี่ยงด้านการจ้างงานที่ใหญ่กว่าคือไม่ใช่ขนาดของ pool แต่คือ แนวโน้มทางความคิด
    • Haskell ดึงดูดคนอุดมคติที่ใส่ใจความถูกต้องและ abstraction ชอบอ่าน paper และตั้งคำถามกับสมมติฐานเดิม
    • ถ้าจุดแข็งนี้ไม่ถูกควบคุม มันอาจกลายเป็นภาระของ production ได้
    • ท่าทีแบบอยากเขียนชั้นฐานข้อมูลใหม่ด้วยการเข้ารหัส relational algebra ระดับ type แบบใหม่, ปฏิเสธการ merge เพราะสคริปต์ใช้แล้วทิ้งใช้ String แทน Text, หรือพยายามลากทุกการออกแบบไปสู่ total rewrite ตาม paper ล่าสุด จะทำให้ทีมช้าลง
  • production Haskell ต้องการ วัฒนธรรมแบบปฏิบัตินิยม
    • type system เป็นเครื่องมือไฟฟ้า ไม่ใช่ศาสนา
    • การมองปัญหาที่มีคำตอบที่ดีอยู่แล้วเป็นโอกาสในการประดิษฐ์กลไกใหม่ ไม่เหมาะกับ production
  • ผลตอบแทนจะค่อย ๆ ปรากฏตามเวลา
    • การรีแฟกเตอร์ที่อาจใช้เวลาหลายสัปดาห์ใน codebase แบบ dynamic type อาจเสร็จได้ในไม่กี่ชั่วโมงหลังเปลี่ยน type เพราะคอมไพเลอร์บอก call site ทั้งหมดให้
    • วิศวกรใหม่สามารถอ่าน type signature แล้วเข้าใจสัญญาของโมดูลได้
    • state ที่เป็นไปไม่ได้อาจไม่ก่อ incident ใน production เพราะมันถูกทำให้ไม่สามารถแทนค่าได้จริง
  • Mercury มองว่าผลตอบแทนจากการลงทุนปรากฏในระดับ ไม่กี่เดือน ไม่ใช่หลายปี
    • โดยเฉพาะในบริการทางการเงิน ต้นทุนของบั๊กด้านความถูกต้องสมบูรณ์ของข้อมูลไม่ได้วัดจากความไม่พอใจของผู้ใช้ แต่จากการถูกหน่วยงานกำกับดูแลทักท้วงและจากเงินของคนอื่น
    • type system ไม่ได้กำจัดความเสี่ยง แต่ให้เครื่องมือที่ทำให้การเผลอนำความเสี่ยงเข้าสู่ codebase ที่เติบโตเร็วเกิดขึ้นได้ยากขึ้น
  • คุณค่าของ Haskell ใน production ไม่ได้อยู่ที่การเป็น silver bullet หรือขบวนการทางศีลธรรม แต่อยู่ที่การเป็นชุดเครื่องมือทรงพลังที่ช่วยให้ทีมซึ่งมีความชำนาญ Haskell หลากหลายระดับ สามารถกักอุปกรณ์อันตรายไว้ในขอบเขต, รักษาความรู้เชิงปฏิบัติการ, และทำให้เส้นทางที่ปลอดภัยกลายเป็นเส้นทางที่ง่าย

1 ความคิดเห็น

 
GN⁺ 2 시간 전
ความคิดเห็นจาก Hacker News
  • จริงอยู่ว่า Haskell เป็นหนึ่งในภาษาที่ทรงพลังที่สุดในการบังคับเรื่องแบบนี้ด้วย type แต่แพตเทิร์นเดียวกันก็ใช้ได้ค่อนข้างดีใน Rust และ TypeScript ด้วย
    ผมชอบวิธีป้องกัน บั๊กด้านการอนุญาตสิทธิ์ ที่ชัดเจนและเกิดซ้ำในเว็บแอป ด้วย flow แบบ User -> LoggedInUser -> AccessControlledLoggedInUser
    ผมมองว่าในอุตสาหกรรมแพตเทิร์นนี้ถูกใช้น้อยกว่าที่ควรอย่างมาก

    • เรื่องนี้ไม่ได้จำกัดแค่ Rust หรือ TypeScript ที่จริงแล้วทำได้แทบทุกภาษา
      ถ้าด้านความปลอดภัยต้องแยกสตริงก่อน/หลัง escape แม้แต่ภาษา dynamic typing ก็ยังห่อด้วยคลาส Escaped แล้วมีฟังก์ชันอย่าง escape(str)->Escaped, dangerouslyAssumeEscaped(str)->Escaped ได้
      มีต้นทุนด้านประสิทธิภาพจึงต้องประนีประนอมบ้าง แต่ทำได้
      อีกวิธีก็คือ Application Hungarian แต่แบบนี้พึ่งพาวินัยของโปรแกรมเมอร์มากกว่าคอมไพเลอร์: https://www.joelonsoftware.com/2005/05/11/making-wrong-code-...
    • นี่ใกล้เคียงกับปัญหาเรื่อง affordance มากกว่าตัว type system เอง
      อย่างเช่นใน C# ก็ทำได้สบาย แต่สุดท้ายมักกลายเป็นว่ามี visual noise มากกว่าคำจำกัดความของ type จริง ๆ
    • แน่นอนว่า Rust กับ TypeScript ได้รับอิทธิพลจาก Haskell อย่างมาก
      เพียงแต่เพื่อหลีกเลี่ยงผลแบบ “monad มันน่ากลัว งั้นเราควรเขียน tutorial” ก็เลยไม่ค่อยพูดตรง ๆ และเรียกชื่อเสียใหม่
      อิทธิพลจาก type class ดูจะมากกว่า monad เสียอีก
    • ผมยังไม่มั่นใจว่าวิธีนี้เวิร์กมากจริงใน TypeScript
      เพราะไม่มี nominal type ถ้าจะทำอะไรคล้าย newtype เพื่อห่อ primitive type ก็ต้องจำคาถาแฮ็ก ๆ พอสมควร
      จากประสบการณ์ผม OCaml ทรงพลังกว่า Rust ในการบังคับ type safety แบบนี้
      มันมี GADT ที่ expressive กว่า และยังมี polymorphic variant กับ object type/record row type ที่ใช้งานสะดวก รวมถึง module system และ functor
      ในงานที่ garbage collection เพียงพอ ก็ยังเลี่ยงข้อจำกัดและความยากของ abstraction ที่เกิดจาก borrow checker ของ Rust ได้ด้วย
    • นี่ก็คือแนวคิด “ทำให้สถานะที่ผิดพลาดไม่สามารถแสดงออกมาได้”: https://news.ycombinator.com/item?id=40150159
  • ผมชอบการทำงานกับ Haskell มากอยู่หลายปี
    ไม่ได้ตั้งใจหาโดยเฉพาะ แต่โอกาสมันเข้ามาเอง และมันทั้งน่าสนใจและกระตุ้นทางปัญญามาก
    แต่ที่น่าเสียดายคือแม้จะใช้ Haskell มา 3 ปีเต็มแล้ว ผลิตภาพของผมใน Rust ก็ยัง มากกว่าง่าย ๆ สองเท่า
    Haskell มีหลุมพรางที่ต้องรู้ล่วงหน้าเพื่อหลีกเลี่ยงมากกว่า และขึ้นกับคนเขียน บางทีก็ย่อยยากจนแทบเป็นภาษาแบบอ่านอย่างเดียว
    toolchain ก็มักผูกกับ Nix ซึ่งตัว Nix เองก็เป็นสัตว์ประหลาดที่ซับซ้อน และ language extension ก็ดูเหมือนกระจายอยู่ทุกที่
    ไฟล์ Cabal ก็ไม่ค่อยดี และต้องใช้เวลาพอสมควรถึงจะชินกับ compiler error

    • น่าแปลกที่ประสบการณ์ของผมแทบตรงกันข้ามเลย
      ในผลิตภัณฑ์ล่าสุดผมเริ่มย้าย backend จาก Typescript ไป Rust เพราะเบื่อกับปัญหาแครช
      ตอนนี้ผมมองว่านั่นเป็นหนึ่งในความผิดพลาดทางเทคนิคครั้งใหญ่ที่สุดที่ผมเคยทำ เพราะผลิตภาพช้าลงมหาศาล
      ตัวอย่างของเวลาที่เสียไปเพราะ Rust โดยเฉพาะ เช่น การเขียน higher-order function ที่เปิด database connection ทำบางอย่างแล้วปิด ซึ่งใน Haskell, TypeScript, JavaScript, C++, PHP เป็นเรื่องจิ๊บจ๊อย แต่ใน Rust ยากจนแม้แต่เพื่อนที่เชี่ยวชาญ Rust ก็ยังบอกว่าแทบเป็นไปไม่ได้ สุดท้ายเลยต้องยอมแพ้
      อีกอย่างคือผมเคยพยายาม refactor แล้วใช้เวลาทั้งวันแก้ type error ก่อนจะไปเจอ error ในไฟล์บนสุด แล้วค่อยรู้ว่าเพราะส่วนพื้นฐานของการออกแบบ มันทำให้การ refactor ทั้งหมดเป็นไปไม่ได้ เลยต้องย้อนกลับทั้งหมดแบบนี้หลายครั้ง
      แถม Rust ยังเป็นภาษา modern ภาษาเดียวที่ผมนึกออก ที่การ ใช้ค่าในฐานะ interface แทน concrete type นั้น อยู่ระหว่างเทคนิคขั้นสูงกับเป็นไปไม่ได้ ขึ้นอยู่กับบริบท
      สุดท้ายผมเลยสรุปว่าโค้ดแอปพลิเคชัน กล่าวคือไม่ใช่โค้ดระบบหรือโค้ดไลบรารี ควรหลีกเลี่ยง Rust โดยประมาณ
    • อยากรู้ว่าโดยรวมแล้วผลิตภาพเพิ่มขึ้น 2 เท่าจริงทุกด้านไหม หรือมีส่วนที่ใน Rust กลับผลิตภาพต่ำลงด้วย
      แล้วคำว่า “อ่านอย่างเดียว” หมายถึงอะไรด้วย
  • ต่างจากภาพจำทั่วไป ผมคิดว่าความจริงที่ว่า Mercury เลือก Haskell และผู้นำช่วงแรกมีประสบการณ์กับ Haskell อย่างลึกซึ้ง อาจมีส่วนสำคัญไม่น้อยต่อความสำเร็จ
    ในฐานะลูกค้าของ Mercury บริษัทนี้เป็นหนึ่งในบริษัทหลักในชุดเครื่องมือของผม และผมสลัดความรู้สึกไม่ได้เลยว่า การเลือก Haskell ทำให้ความก้าวหน้า การพัฒนา และเส้นทางโดยรวมของพวกเขาดีขึ้น
    แน่นอนว่าข้ออ้างแบบนี้พูดได้กับแทบทุกภาษา และไม่ได้แปลว่าภาษาฟังก์ชันอย่าง Haskell คือสูตรสำเร็จ
    แต่การตัดสินใจอย่างตั้งใจแบบนี้ก่อนยุค “vibe coding” และก่อนยุค LLM ดูมีวิสัยทัศน์เป็นพิเศษ และผมมองว่ามันเป็นผลร่วมกับวัฒนธรรมวิศวกรรมที่บทความอธิบายไว้ละเอียด

    • ปัจจัยความสำเร็จอาจเป็นเรื่อง โฟกัสฟินเทคแบบมุ่งสตาร์ตอัป และความสามารถในการลงมือทำมากกว่า
      ผมเองก็ชอบวัฒนธรรมเทคนิคที่ดี แต่ก็เคยเห็นบริษัทที่มีวัฒนธรรมเทคนิคเยี่ยมตายเพราะโฟกัสทางธุรกิจแย่
      ยิ่งไปกว่านั้น วัฒนธรรมฟินเทคแบบสตาร์ตอัปอาจเป็นตัวก่อให้เกิดวัฒนธรรมเทคนิคที่ดีก็ได้
      เพราะไม่ได้เริ่มจากการเป็นธนาคาร จึงไม่ต้องอนุรักษนิยมขนาดนั้นเหมือน SVB และไม่ต้องไปผูกกับ tech stack โบราณสุดสยอง
      ผมดีใจที่เห็น Haskell ประสบความสำเร็จ แต่เหมือน Jane Street กับ OCaml ผมคิดว่าตรงข้ามกับที่บริษัทอยากให้เราเชื่อ การเลือกภาษานั้นในเชิงธุรกิจแทบเป็นเรื่องบังเอิญ
      แต่อยากรู้ว่าฝั่ง frontend ใช้อะไร คิดว่า Haskell ตรงนี้น่าจะเป็น backend ทั้งหมด
    • การรับ generalist ที่ไม่มีประสบการณ์กับภาษานั้นมาก่อนอาจกลับช่วยได้ด้วย
      เพราะสามารถปลูกฝังวัฒนธรรมและสไตล์ให้คนใหม่ได้ตั้งแต่ต้น
      ถ้าเป็นก่อนยุค vibe coding คนพวกนี้ส่วนใหญ่ก็คงไม่กระโดดเข้าไปแฮ็กอะไรแบบไร้ทิศทางโดยไม่มีคำแนะนำอยู่แล้ว
    • ผมรู้สึกได้ว่าในแอป ทุกอย่างมันทำงานได้ดีเฉย ๆ
      ถ้าย้ายมาจากบริการอื่นจะพอใจมากจริง ๆ
  • เพื่อนสนิทของผมทำงานที่บริษัทนี้ และต่อให้มองจากข้างนอก วัฒนธรรมวิศวกรรม ก็ดูดีมาก
    ผมคิดว่า Haskell เป็นเครื่องมือที่เหมาะกับงานนี้และพวกเขาก็ใช้จุดแข็งของมันได้ดี แต่ก็อดคิดไม่ได้ว่าส่วนใหญ่ของความสำเร็จอาจเป็นเพราะบริษัทบริหารจัดการได้ดีโดยรวม

    • ผมก็รู้สึกแบบนั้นตอนอ่านบทความ
      รู้สึกว่าผู้เขียนคนนี้ต่อให้ใช้ภาษาอะไรก็น่าจะสร้างองค์กรวิศวกรรมที่ประสบความสำเร็จได้
    • มันก็ไม่ได้ขัดกับความคิดที่พบบ่อยว่า การใช้ภาษาฟังก์ชันช่วยคัดกรองคนเก่ง/กลุ่มผู้สมัครที่มีคุณภาพสูงกว่าได้
  • ตอนนี้ผมกำลังอ่าน Real-World OCaml อยู่ และถึงจะรู้บางอย่างอยู่แล้ว ก็ยังได้เรียนรู้ functional programming มากขึ้น
    ดูเหมือนว่า functional programming จะช่วยสร้างชิ้นส่วนซอฟต์แวร์ที่แข็งแรงอย่างน่าประหลาดได้
    แต่ผมก็ลังเลอยู่
    ตอนนี้ backend ของผลิตภัณฑ์รันด้วย NiceGUI และมันก็ทำหน้าที่ได้ดี
    โค้ดสมเหตุสมผล เป็น MVVM และงานสำคัญที่สุดคือเชื่อมต่อ websocket แยกตามลูกค้าเพื่อรับข้อมูลและแสดงการวิเคราะห์
    จำนวนลูกค้าน่าจะไม่มาก และผู้เยี่ยมชมเว็บไซต์ก็คงมีตั้งแต่หลักสิบถึงอย่างมากหลักร้อย
    ผมก็อยากได้ REPL หรือ hot reload ด้วย และรู้ว่าพอฟีเจอร์เพิ่มขึ้น functional programming อาจเหมาะกับการแปลง data pipeline อย่างพวกหน้าจัดการผู้ใช้หรือ analytics เพิ่มเติม
    แต่ Haskell หรือ OCaml ก็เป็นภาษาสถิต
    ถ้าอยากได้ความ dynamic และยังขยายได้ในอนาคต Clojure หรือ Elixir ก็ดูน่าจะเป็นตัวเลือกที่ดี
    พร้อมกันนั้นผมก็กลัวว่าถ้าวันหนึ่งต้อง refactor มันจะพัง
    ตอนนี้ใช้ Python กับ Mypy และ frontend ให้ NiceGUI สร้างจากฝั่ง backend

    • ไม่แน่ใจเรื่อง OCaml แต่ใน Haskell คุณ reload เว็บแอปที่กำลังพัฒนาได้เร็วมากด้วย ghci/cabal repl
      พูดตรง ๆ ผมคิดว่าผู้ใช้ Haskell หลายคนกลับใช้ประโยชน์จากสิ่งนี้น้อยเกินไป
  • ผมเคยทำระบบคล้ายกันด้วยภาษาเฉพาะกลุ่มอย่าง Scheme และต่อมาก็ Racket แม้สเกลจะโตขึ้น ทีมเล็กก็ยังดูแลได้นานและรักษาความเร็วไว้ได้
    เราไม่ได้สร้างบั๊กเยอะ และมักเพิ่มฟีเจอร์ได้เร็วมาก
    ตัวอย่างเช่น เราเป็นกลุ่มแรกที่ผ่านการรับรองบางอย่างเพื่อโฮสต์ข้อมูลอ่อนไหวบน AWS
    บางครั้งการเพิ่มฟีเจอร์ก็ช้าลง เพราะบนแพลตฟอร์มยอดนิยม งานที่ปกติใช้คอมโพเนนต์สำเร็จรูปได้ เราต้องสร้างเองจากศูนย์
    แต่พอสร้างเสร็จแล้วมันก็ทำงานได้ดี และเราก็กลับไปสู่ความเร็วเดิม โดยไม่ถูกความบวมและความซับซ้อนของเฟรมเวิร์กสำเร็จรูปนับสิบถ่วงลง
    เพราะเราควบคุมแพลตฟอร์มที่จัดการได้เอง จึงย้ายไป AWS ได้เร็วเมื่อจำเป็น
    ระบบนี้ยังมี เคล็ดลับสถาปัตยกรรม สำหรับข้อมูลซับซ้อนและการโต้ตอบบนเว็บตั้งแต่แรก ซึ่งช่วยให้พัฒนาฟีเจอร์จำนวนมากได้เร็ว และยังส่งแรงไปในทิศทางที่ฉลาดต่อมาอีกด้วย
    จุดต่างจากฟินเทค Haskell คือทีมเล็กมาก
    มีวิศวกรซอฟต์แวร์พร้อมกันแค่ 2-3 คน และมีคนที่ดูแล operation ทั้งหมด
    เลยไม่มีปัญหาการประสานงานของคนนับร้อยเพื่อรักษาระบบให้สอดคล้องกัน
    โดยทั่วไปคนหนึ่งจะรับผิดชอบการเปลี่ยนแปลงโค้ดเชิงเทคนิคและสถาปัตยกรรมมากกว่า ส่วนอีกคนจะเพิ่มฟีเจอร์ business logic จำนวนมากของกระบวนการซับซ้อนได้อย่างรวดเร็ว
    ถ้าใช้เครื่องมือ AI กลุ่ม LLM ในปัจจุบันหรืออนาคตอันใกล้อย่างระมัดระวัง ผมคิดว่าเราอาจได้ประสิทธิภาพแบบทีมเล็กแต่ทรงพลังมหาศาลกลับมาบางส่วนในการพัฒนาซอฟต์แวร์
    โมเดลที่ผมนึกถึงไม่ใช่การผลิตความบวมขนาดใหญ่เพื่อกำจัด story point แล้วโยนความยั่งยืนให้คนอื่นรับผิดชอบ แต่เป็นการให้คนคิดคม ๆ จำนวนน้อยคอยขับระบบไปข้างหน้า ขณะเดียวกันก็รักษามันให้อยู่บนเส้นทางที่จัดการได้

  • มันเป็นดาบสองคม
    2 ล้านบรรทัด เป็นความสำเร็จที่น่าทึ่ง แต่ในเวลาเดียวกันก็เป็นภาระด้านการบำรุงรักษาที่ไม่น้อย
    ข้อดีของ Haskell นั้นชัดเจนในทางทฤษฎี แต่ข้อเสียกลับหยั่งถึงได้ยากกว่า
    สิ่งยั่วยวนคือการพยายามโมเดลทุกอย่างด้วย type
    codebase เองจะกลายเป็น สเปกทางธุรกิจ แทนที่จะเป็นแอปพลิเคชัน
    ทุกครั้งที่นโยบายเปลี่ยนก็กลายเป็น refactor ใหญ่ และด้วยความปลอดภัยของ Haskell มันอาจใช้แรงอย่างน่าประหลาด
    สุดท้ายคุณไม่มีทางได้ทั้งสองอย่าง และวันหนึ่งก็จะติดกับ type เอง
    Haskell น่าประทับใจและทรงพลังมาก โดยเฉพาะในสเกลนี้ แต่ก็นำปัญหาเฉพาะตัวมาด้วย
    ความยั่วยวนที่จะโมเดล business logic ด้วย type อาจสร้างโครงสร้างที่แข็งทื่อ และความปลอดภัยที่โครงสร้างนั้นมอบให้ ก็อาจทำให้มองไม่เห็นความเสี่ยงอีกแบบหนึ่ง

    • ถ้าวิศวกรแกนหลักเป็นคนมีประสบการณ์และมีรสนิยมที่ดี ก็เดินบนเส้นนั้นได้ค่อนข้างดี
      คุณอาจไม่ได้ทุกอย่าง แต่ได้หลายอย่าง
      ผมเคยไปฝึกงานที่ Jane Street เมื่อหลายปีก่อน ถึงจะเป็น OCaml ไม่ใช่ Haskell แต่ดูเหมือนพวกเขาจับสมดุลนี้ได้ดีมาก
      ทั้งที่อยู่ในโดเมนซึ่งมีความซับซ้อนโดยเนื้อแท้สูง และความน่าเชื่อถือกับความถูกต้องเชื่อมตรงกับการอยู่รอดของธุรกิจ แต่ก็ยังเคลื่อนที่ได้เร็วอย่างน่าทึ่ง
      ย้อนกลับไป ผมคิดว่าแก่นของ Jane Street คือการจ้าง โปรแกรมเมอร์ OCaml ที่มากประสบการณ์และมีรสนิยมยอดเยี่ยมอย่าง Stephen Weeks แล้วให้พวกเขาสร้าง core library ตั้งแต่ต้นและนำทาง codebase ทั้งหมด
      น่าเสียดายที่ Mercury ทำเรื่องนี้ได้ไม่ดีเท่าไร
    • TypeScript ก็เหมือนกัน: https://www.richard-towers.com/2023/03/11/typescripting-the-...
      พูดตรง ๆ ข้อเสียใหญ่ที่สุดของ type system ที่ Turing-complete คือในทางทฤษฎีคุณสามารถสร้างแอปพลิเคชันที่เมื่อคอมไพล์แล้วกลายเป็นฝุ่นได้
  • กรณีความสำเร็จของ Haskell ที่ Bellroy ซึ่งคล้ายกัน จะเป็นหัวข้อในงานมีตอัป Melbourne Compose ที่กำลังจะจัด: https://luma.com/uhdgct1v

  • ปัญหาที่ผมเจอใน functional programming คือ การดีบัก
    หรือพูดให้ชัดกว่านั้น ผมมองว่านี่คือจุดแข็งของ imperative programming โดยเฉพาะแบบ procedural
    ในสไตล์ functional/declarative ปกติคุณอธิบายว่าอะไรควรเป็นสถานะ ไม่ใช่อธิบายว่ามันถูกสร้างขึ้นมาอย่างไร แล้วภาษาเป็นคนประกอบทุกอย่างจนได้ผลลัพธ์สุดท้าย
    ถ้าทุกอย่างถูกต้องก็ดีและอาจดีกว่าด้วยซ้ำ แต่ถ้าไม่ใช่จนผลลัพธ์ออกมาไม่ตรงคาด คำถามคือจะหาบั๊กยังไง
    ในภาษาอย่าง C มันค่อนข้างตรงไปตรงมา
    คุณไล่ทีละบรรทัด ดูสถานะการทำงานระหว่างแต่ละขั้น ซึ่งก็คือ RAM โดยพฤตินัย แล้วถ้ามันไม่ตรงคาด บรรทัดนั้นก็มีอะไรผิด และก็เจาะเข้าไปแบบนั้นต่อ
    ยิ่งภาษาแบบ functional พยายามซ่อนสถานะมากเท่าไร เรื่องนี้ก็ยิ่งยาก
    น่าสนใจที่ส่วนที่ยาวที่สุดในบทความคือปัญหานี้ หรือก็คือ “design for introspection”
    ผู้เขียนต้องลงแรงอย่างตั้งใจมากเพื่อทำให้โค้ดดีบักได้ และมันให้มุมมองที่ดีเกี่ยวกับการใช้ Haskell ในทางปฏิบัติที่มักถูกมองข้าม

    • เคล็ดลับการดีบักของผมคือทำให้โค้ดทุกส่วนที่มีความสำคัญแม้เพียงเล็กน้อย คืนค่า output เดิมเสมอเมื่อได้รับ input เดิม
      แม้แต่โค้ดเล็กน้อยก็เช่นกัน
      ภาษากระแสหลักอื่น ๆ เข้าใกล้สิ่งนี้ไม่ได้เลย
      กรณีที่ทำแบบนั้นไม่ได้ เช่น shared-memory concurrency ก็ใช้ transaction
      เรื่องนี้ภาษากระแสหลักอื่นก็ยังเทียบไม่ติด
      ยังไม่ต้องนับข้อดีง่าย ๆ อย่างไม่มี null หรือไม่มี implicit integer casting ด้วยซ้ำ
      ที่บอกว่าการดีบักโค้ด Haskell ยากกว่าภาษาอื่นนั้นถูกต้องเต็ม ๆ
      แต่เมื่อคุณตัดปัญหาถ่วงขา 90% ล่างออกไปแล้ว มันก็ย่อมเป็นแบบนั้น
    • การดีบักใน functional programming มักจะ ขับเคลื่อนด้วย REPL มากกว่า ต่างจาก imperative programming
      แน่นอนว่านี่ไม่ใช่เรื่องเฉพาะของ functional เท่านั้น ในภาษาอย่าง Python หรือ JavaScript ที่โดยมากเป็น imperative คนก็ใช้ Python shell, browser console, Node/Deno/Bun shell, notebook ฯลฯ เป็นชั้นแรกของการดีบักกันบ่อย
      การดีบักแบบเน้น REPL มี trade-off ที่น่าสนใจ
      ในภาษาอย่าง C มักเริ่มจากการดีบักทั้งโปรแกรมและตั้ง breakpoint โดยพยายามเดาให้ถูกจุดเป๊ะว่าปัญหาน่าจะอยู่ตรงไหน
      แต่ในโลกแบบ REPL-first คุณจะพยายามทำให้ส่วนประกอบของโปรแกรมสามารถทดสอบใน REPL ได้โดยตรงมากขึ้น
      ดังนั้นขอบเขตของ module/API/type จึงเริ่มคล้ายกับความสามารถในการดีบัก
      บางครั้งจึงมีแรงกดดันมากกว่าภาษา imperative อย่าง C/C++ ให้สร้างขอบเขตเหล่านี้ให้ดีและใช้ง่าย
      ในทางกลับกัน เมื่อเทียบกับการดีบักแบบทั้งโปรแกรมก่อน มันก็อาจยากกว่าที่จะแยกปัญหาการรวมกันของหลายหน่วยในสถานการณ์จริงแปลก ๆ
      แต่แนวทาง REPL-first มักผลักให้ลด พื้นที่ผิว ของการบูรณาการลงให้ต่ำที่สุด จึงทำให้ในภาษาฟังก์ชันมักมีผลจากการรวมระบบแบบที่เห็นในภาษา imperative น้อยกว่า
      การพูดว่าภาษาฟังก์ชันซ่อนสถานะนั้นไม่ค่อยถูก
      ภาษาเหล่านี้ก็รันบนฮาร์ดแวร์แบบ imperative และจัดการกับสถานะจริงของฮาร์ดแวร์เหมือนกัน
      ณ จุดหนึ่งมีการแปลระหว่างสองโลกนี้ และมันอาจไม่ได้ต่างกันอย่างที่คิด
      หากจำเป็นคุณก็ยังกลับไปใช้ breakpoint แบบ imperative และ debugger แบบ imperative ได้
      นั่นจึงเป็นเหตุผลที่ผมเรียกมันว่า “การดีบักแบบ REPL-driven”
      คุณใช้ REPL เพื่อลดขอบเขตให้เหลือหน่วยที่มีปัญหา คือ module/API/function ที่แน่ชัด พร้อม input ที่ทำให้เกิด output แปลก
      ถ้ามองจาก source อย่างเดียวแล้วยังไม่เห็นบั๊ก ก็ส่งต่อไปยัง debugger แบบ imperative เพื่อดูประสบการณ์ไล่ทีละบรรทัดที่เกือบเหมือนกัน และอาจได้บริบทเพิ่มเติม
      พอมาถึงจุดนั้นคุณก็มักลดขอบเขตด้วย REPL มาได้มากพอแล้ว จนหน่วยนั้นเล็กและแคบมาก จึงอาจไม่ต้องเลือก breakpoint ที่ดีมากนัก
      ผมคิดว่าคุณจับสารของส่วน “design for introspection” ในบทความผิดไปหน่อย
      ส่วนนั้นพูดถึง observability ไม่ใช่ความสามารถในการดีบัก
      เขาพูดถึงการต่อระบบ logging/telemetry ให้ถูกต้อง การ mock ของปลอมระหว่างทดสอบ และการใส่ retry/circuit breaker ในระดับทั้งระบบ แทนที่จะปล่อยให้เป็นหน้าที่ของแต่ละไลบรารี
      ในโลก imperative สิ่งนี้ก็ไม่ใช่ปัญหาการดีบักเหมือนกัน แต่มันเป็นปัญหาเรื่องการแยกส่วน เช่น dependency injection, การติดตั้ง middleware, หรือการใช้อินเทอร์เฟซนามธรรมแทน concrete class ที่ขอบเขต public API
      ข้อเสนอด้านการออกแบบแบบนี้คือการ refactor และมันส่งผลต่อว่าคุณจะติดตั้ง observability middleware ลงบน public API ของคนอื่นได้ง่ายแค่ไหน มากกว่าจะเกี่ยวกับความสามารถในการดีบัก
  • ผมนึกภาพไม่ออกเลยว่า Haskell 2 ล้านบรรทัด มันเอาไปทำอะไรกันบ้าง
    โค้ดมันเยอะมาก ทั้งที่ Haskell ให้ความรู้สึกเป็นภาษาที่ “หนาแน่น” ซึ่งใช้โค้ดน้อยแต่ทำงานได้มาก
    อาจเป็นเพราะมีไลบรารีสำหรับ JSON serialization/deserialization, REST API framework, logging อะไรพวกนี้เยอะหรือเปล่า

    • ตามต้นฉบับ ปัญหาคือโค้ดที่ทำ instrumentation ไม่ได้ก็เชื่อถือไม่ได้
      ถ้า third-party binding ทำ HTTP call ผ่านฟังก์ชัน concrete ก็ไม่มีทางเพิ่ม trace ได้ ไม่มีทาง inject timeout ให้ตรงตาม SLO ได้ ไม่มีทางจำลอง partner outage ในการทดสอบได้ และไม่มีทางอธิบายช่องว่าง 400ms ใน trace ได้นอกจากเดาเอาตามทฤษฎี
      เพราะอย่างนั้นพวกเขาจึงเขียนเอง
      ตอนต้นจะงานเยอะกว่า แต่ client ที่เขียนเองถูกทำมาแบบนั้นตั้งแต่แรก จึง ถูกจัดวางให้สังเกตการณ์ได้
    • คุณสมบัติที่เรียกว่า “หนาแน่น” นั้น ปกติจะเรียกว่า expressive สูง
      หมายถึงสามารถสื่อแนวคิดที่ค่อนข้างนามธรรมมากด้วยตัวอักษรเพียงเล็กน้อย
      บางคนก็เรียกสิ่งนี้ว่า “ระดับสูง”
      แต่ผมก็คิดว่า 2 ล้านบรรทัดไม่ได้มากอย่างที่ฟังครั้งแรกนัก
      โดยเฉพาะถ้าเป็นบริษัทในโดเมนที่มีกฎระเบียบหนักอย่างการเงิน และเป็นโค้ดที่สะสมมาหลายปี
    • แม้จะไม่ใช่มาตรวัดเชิงวัตถุวิสัยเลย แต่ผมรู้สึกว่า Haskell แค่มี อัตราส่วนกว้างยาว คนละแบบ
      จำนวนบรรทัดอาจน้อยกว่าได้บ้าง แต่จำนวนคำโดยรวมกลับใกล้เคียงกับภาษาวัตถุเชิงคำสั่งมากกว่า
    • ผมไม่รู้ว่า codebase จริงเป็นยังไง แต่ชื่อเสียงเรื่องความกระชับของ Haskell ส่วนหนึ่งเกิดจากการที่โลกสายวิชาการหรือ category theory ถูกนำเสนอมากเกินจริง
      ในโลกนั้น St M -> C T อาจโอเค แต่ในซอฟต์แวร์จริง TransactionState Debit -> Verified Transaction มีประโยชน์กว่ามาก
      อีกส่วนหนึ่งเป็นปัจจัยทางวัฒนธรรมที่ย้อนไปถึง LISP
      คนมักฉลาดเกินพอดีเพื่อประหยัดจำนวนบรรทัด ด้วยกลเม็ดหรือ macro ที่เข้าใจยาก
      แต่ในบริษัทการเงินอย่าง Mercury น่าจะส่งเสริมความชัดเจนและอ่านง่ายมากกว่าวิธีแบบนั้น
      เช่น linter อาจบังคับให้แยกโค้ด monad ออกเป็น do expression หลายบรรทัดอย่างละเอียด แทนการเขียนในบรรทัดเดียวด้วย >> และ >>=