ข้าม​ไป​ยัง​เนื้อหา

Tramp Data

ข้อมูล​ที่​ถูก​ส่ง​ผ่าน​หลาย method โดยที่ method กลาง​ทาง​ไม่​ได้​ใช้

Tramp Data คือ​กลิ่น​ที่​ข้อมูล​ชิ้น​หนึ่ง เดิน​ทางผ่าน​หลาย method ใน​ฐานะ parameter แต่ method กลาง​ทาง​ไม่​ได้​ใช้​หรือ​สนใจ​มัน​เลย — หน้าที่​เดียว​ของ method กลาง​ทาง​คือ​รับ parameter นั้น​มา​แล้ว​ส่ง​ต่อ​ไป​ให้ method ถัด​ไป​เฉย ๆ ข้อมูล​จึง​กลาย​เป็น “คน​พเนจร” (tramp) ที่​ย้าย​ที่​ไป​เรื่อย ๆ โดย​ไม่มี “บ้าน” ที่แท้​จริง​ใน code

ชื่อ​และ​แนวคิด​ของ​กลิ่น​นี้​มา​จาก​วงการ code smell แบบ Steve McConnell (Code Complete) และ​ถูก​นำ​มา​จัด​หมวด​ใน DevIQ ควบคู่​กับ Long Parameter List และ Message Chain ต่าง​จาก Message Chain ที่​ปัญหา​อยู่​ที่​การ “ไล่​เรียก” ผ่าน​เชน (a.getB().getC().getD()) Tramp Data มอง​จาก​อีก​มุม​คือ “parameter” ที่​ถูก​ส่ง​ต่อ​กัน​เป็น​ทอด ๆ ผ่าน​ลำดับ​การ​เรียก method (call chain) — สอง​กลิ่น​นี้​จึง​มัก​เกิด​คู่​กัน​ใน code เดียวกัน เพราะ​การ​ไล่​เรียก​เชน​บ่อย​ครั้ง​ก็​คือ​การ “แบก” ข้อมูล​ผ่าน method กลาง​ทาง​นั่นเอง

  • method มี parameter ที่ ไม่​ได้​ใช้​ใน​ตัวเอง เลย นอกจาก​ส่ง​ต่อ​ให้ method ที่​มัน​เรียก​ภายใน
  • เวลา​ต้อง​เพิ่ม field ใหม่​ใน​ข้อมูล​ก้อน​หนึ่ง ต้อง​แก้ signature ของ method หลาย​ชั้น​ตลอด​ทาง ทั้ง​ที่ method กลาง​ทาง​ไม่​เคย​แตะ​ข้อมูล​นั้น
  • ชื่อ parameter ซ้ำ ๆ กัน​ปรากฏ​ใน signature ของ method หลาย​ตัว​ติด​กัน​เป็น​สาย (A เรียก B เรียก C เรียก D โดย parameter เดิม​ไหล​ผ่าน​ทุก​จุด)
  • เขียน unit test ให้ method กลาง​ทาง​แล้ว​รู้สึก​แปลก ๆ เพราะ​ต้อง​สร้าง mock/stub ของ​ข้อมูล​ที่ method นั้น​ไม่​ได้​ใช้​จริง เพียง​เพื่อ​ให้ compile ผ่าน
  • method utility หรือ helper กลาย​เป็น “ท่อ” (pipe) ที่​มี parameter ยาว​ขึ้น​เรื่อย ๆ ตาม​เวลา เพราะ​ทุก​ครั้ง​ที่​ปลายทาง​ต้องการ​ข้อมูล​เพิ่ม คน​ก็​เพิ่ม parameter ผ่าน​ทุก​ชั้น

Interface ไม่​ตรง​กับ​สิ่ง​ที่ method ทำ​จริง — เมื่อ signature ของ method มี parameter ที่​มัน​ไม่​แตะ​เลย มัน​โกหก​ผู้​อ่าน​ว่า method นี้ “เกี่ยวข้อง” กับ​ข้อมูล​นั้น ทั้ง​ที่​จริง ๆ ไม่ใช่ ทำให้​ตีความ​เจตนา​ของ code ได้​ยาก​ขึ้น

ผูก​กับ Law of Demeter — Tramp Data มัก​เป็น​อาการ​ของ​การ​ละเมิด Law of Demeter: function ควร​อยู่​ใกล้​ข้อมูล​ที่​มัน​ดำเนิน​การ​ด้วย เมื่อ​ข้อมูล​ต้อง​เดิน​ทางผ่าน​ชั้น​ที่​ไม่​เกี่ยวข้อง​เพื่อ​ไป​ถึง​จุด​ที่​ใช้​จริง แปล​ว่า​โครงสร้าง class/module จัด​วาง​ความ​รับผิดชอบ​ผิด​ที่

เปราะ​ต่อ​การ​เปลี่ยนแปลง (Shotgun Surgery) — ถ้า​ข้อมูล​ที่​แบก​ผ่าน​ต้อง​เปลี่ยน​รูปร่าง (เพิ่ม field, เปลี่ยน​ชนิด) ทุก method ตลอด​ทาง​ต้อง​แก้ signature แม้​จะ​ไม่​ได้​ใช้​ข้อมูล​นั้น​เลย​ก็ตาม ยิ่ง​เชน​ยาว ยิ่ง​แก้​กระทบวง​กว้าง

parameter ยาว​ตาม​เวลา — method กลาง​ทาง​มัก​จบ​ลง​ด้วย Long Parameter List เพราะ​ทุก​ครั้ง​ที่​ปลายทาง​ต้องการ​ข้อมูล​เพิ่ม วิธี​ที่​ง่าย​ที่สุด (แต่​ผิด) คือ​เพิ่ม parameter ผ่าน​ทุก​ชั้น​ที่​คั่น​อยู่

ทดสอบ​ยาก — method กลาง​ทาง​ที่​ไม่​ได้​ใช้​ข้อมูล​แต่​ต้อง​รับ​มัน​เข้า​มา บังคับ​ให้ test ของ method นั้น​ต้อง​สร้าง/mock ข้อมูล​ที่​ไม่​เกี่ยวข้อง​กับ​สิ่ง​ที่​กำลัง​ทดสอบ เพิ่ม noise ให้​ทั้ง production code และ test code

ตัวอย่าง​ระบบ​ออก​ใบ​แจ้ง​หนี้: discountRate และ currency ต้อง​เดิน​ทางผ่าน ProcessOrder ไป​ยัง CalculateTotal แล้วไป​ยัง ApplyPricing ซึ่ง​เป็น​จุด​เดียว​ที่​ใช้งาน​จริง — method กลาง​ทาง​สอง​ตัว​แบก parameter โดย​ไม่​แตะ​ต้อง​มัน​เลย

// ก่อน — discountRate และ currency เป็น tramp data ที่ไหลผ่าน ProcessOrder และ CalculateTotal
public class OrderService
{
public decimal ProcessOrder(Order order, decimal discountRate, string currency)
{
// method นี้ไม่ได้ใช้ discountRate หรือ currency เลย แค่ส่งต่อ
return CalculateTotal(order, discountRate, currency);
}
private decimal CalculateTotal(Order order, decimal discountRate, string currency)
{
// method นี้ก็ไม่ได้ใช้ discountRate หรือ currency โดยตรง แค่ส่งต่ออีกที
decimal subtotal = order.Items.Sum(i => i.Price * i.Quantity);
return ApplyPricing(subtotal, discountRate, currency);
}
private decimal ApplyPricing(decimal subtotal, decimal discountRate, string currency)
{
// จุดเดียวที่ใช้ discountRate และ currency จริง ๆ
decimal discounted = subtotal * (1 - discountRate);
return CurrencyConverter.Convert(discounted, currency);
}
}

การ refactor ที่ 1 — Introduce Parameter Object: รวม discountRate และ currency (และ​ข้อมูล​ราคา​ที่​เกี่ยวข้อง​อื่น ๆ) เป็น class เดียว​ชื่อ PricingContext ลด noise ใน signature และ​เปิด​ทาง​ให้​เพิ่ม field ใน​อนาคต​โดย​ไม่​ต้อง​แก้​ทุก​ชั้น

// หลัง — รวม parameter ที่เดินทางด้วยกันเป็น PricingContext เดียว
public sealed record PricingContext(decimal DiscountRate, string Currency);
public class OrderService
{
public decimal ProcessOrder(Order order, PricingContext pricing)
{
return CalculateTotal(order, pricing);
}
private decimal CalculateTotal(Order order, PricingContext pricing)
{
decimal subtotal = order.Items.Sum(i => i.Price * i.Quantity);
return ApplyPricing(subtotal, pricing);
}
private decimal ApplyPricing(decimal subtotal, PricingContext pricing)
{
decimal discounted = subtotal * (1 - pricing.DiscountRate);
return CurrencyConverter.Convert(discounted, pricing.Currency);
}
}

การ​รวม parameter ช่วย​ลด​ความ​ยาว​ของ signature แต่ ไม่​ได้​แก้​ปัญหา​ที่​ต้นเหตุProcessOrder และ CalculateTotal ยัง​คง​ต้อง​แบก pricing ผ่าน​ตัวเอง​อยู่ดี เพียง​แค่​แบก​ก้อน​เดียว​แทนที่​จะ​แบก​สอง​ก้อน

การ refactor ที่ 2 — Move Method / มอบหมาย​ให้​ผู้​ที่ “เป็น​เจ้าของ” ข้อมูล: ถ้า pricing logic ทั้งหมด​ไม่​เกี่ยว​กับ​การ​ไหล​ของ Order ผ่าน OrderService เลย ให้​ย้าย pricing calculation ออก​ไป​เป็น class ของ​มัน​เอง แล้ว​ให้ OrderService เรียก​ใช้​โดยตรง​ใน​จุด​ที่​ต้องการ ไม่​ต้อง​ผ่าน method กลาง​ทาง​ที่​ไม่​เกี่ยวข้อง

// หลัง — PricingCalculator เป็นเจ้าของ pricing logic โดยตรง ไม่มีข้อมูลพเนจรอีกต่อไป
public class PricingCalculator
{
public decimal Calculate(decimal subtotal, PricingContext pricing)
{
decimal discounted = subtotal * (1 - pricing.DiscountRate);
return CurrencyConverter.Convert(discounted, pricing.Currency);
}
}
public class OrderService
{
private readonly PricingCalculator _pricingCalculator;
public OrderService(PricingCalculator pricingCalculator)
{
_pricingCalculator = pricingCalculator;
}
public decimal ProcessOrder(Order order, PricingContext pricing)
{
decimal subtotal = order.Items.Sum(i => i.Price * i.Quantity);
// เรียก pricing calculator ตรง ๆ ที่จุดที่ต้องการผลลัพธ์ ไม่ต้องมี method กลางทางแบกข้อมูลอีก
return _pricingCalculator.Calculate(subtotal, pricing);
}
}

ตอน​นี้ pricing ถูก​ใช้​ทันที​ที่​จุด​ที่ OrderService.ProcessOrder เรียก ไม่มี​ชั้น​กลาง​ทาง​ที่​แบก​มัน​โดย​ไม่​ใช้​อีก​ต่อ​ไป — และ​ถ้า​ใน​อนาคต pricing logic ต้องการ​ข้อมูล​เพิ่ม ก็​แก้​แค่ PricingContext กับ PricingCalculator โดย​ไม่​กระทบ OrderService

flowchart LR
    Before["ก่อน: OrderService -> CalculateTotal -> ApplyPricing (แบก discountRate, currency ตลอดทาง)"]
    After["หลัง: OrderService เรียก PricingCalculator ตรง ที่จุดใช้งานเดียว"]
    Before -->|Move Method + Introduce Parameter Object| After

หลักการ​เลือก​วิธี​แก้: ถ้า​ข้อมูล​ที่​แบก​ผ่าน​เป็นกลุ่ม​ก้อน​เดียวกัน​เสมอ ให้​เริ่ม​จาก Introduce Parameter Object เพื่อ​ลด noise ก่อน จาก​นั้น​พิจารณา​ว่า method กลาง​ทาง​จำเป็น​ต้อง​อยู่​ใน​เส้นทาง​นี้​จริง​หรือ​ไม่ — ถ้า​ไม่ ให้​ใช้ Move Method หรือ Hide Delegate เพื่อ​ให้​ผู้​เรียก​เข้าถึง​ปลายทาง​ได้​โดย​ไม่​ต้อง​ผ่าน​ตัวกลาง​ที่​ไม่​เกี่ยวข้อง หรือ​ถ้า code เรียกออบ​เจ็กต์ที​ละ field ก่อน​ส่ง​ต่อ ให้​พิจารณา Preserve Whole Object เพื่อ​ส่งทั้งออบ​เจ็กต์แทนที่​จะ​แยก​ส่ง​ที​ละ​ค่า

  • Long Parameter List — อาการ​ที่​มัก​เกิด​คู่​กับ Tramp Data เมื่อ parameter พเนจร​สะสม​มาก​ขึ้น​เรื่อย ๆ
  • Message Chains — กลิ่น​ใน​ตระกูล​เดียวกัน มอง​จาก​มุม​ของ​การ​ไล่​เรียก​เชน​แทน​การ​แบก parameter
  • Law of Demeter Violations — หลักการ​ที่ Tramp Data มัก​ละเมิด
  • Law of Demeter — ตัว​กฎ​ที่​อธิบาย​ว่า​ทำไม​ข้อมูล​ควร​อยู่​ใกล้​จุด​ที่​ใช้
  • Single Responsibility — method กลาง​ทาง​ที่​แบก​ข้อมูล​ไม่​เกี่ยวข้อง​มัก​ทำ​หน้าที่​เกิน​กว่า​ความ​รับผิดชอบ​เดียว​ของ​มัน
  • Data Clumps — เมื่อ​กลุ่ม​ข้อมูล​เดียวกัน​ปรากฏ​ซ้ำ ๆ ใน​หลาย signature ก็​เป็น​สัญญาณ​ว่า​ควร​รวม​เป็น class เดียว เช่น​เดียว​กับ​ที่​ใช้​แก้ Tramp Data