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

Model-View-Presenter

Presenter ถือ reference ไป​ยัง View ผ่าน interface — ทดสอบ​ได้​เต็ม​ที่

ปัญหา​ที่ MVP แก้​คือ​ปัญหา​เดียว​กับ​ที่ MVC พยายาม​แก้ นั่น​คือ logic การนำ​เสนอ (presentation logic) — กฎ​ว่า UI ควร​ตอบ​สนอง​อย่างไร​ต่อ input ของ​ผู้​ใช้, ควร​แสดง/ซ่อน​อะไร​เมื่อไหร่, ควร format ข้อมูล​อย่างไร​ก่อน​แสดง​ผล — มัก​ถูก​ฝัง​ปน​อยู่​ใน code-behind ของ UI widget โดยตรง ทำให้​ทดสอบ​ได้​ยาก​หรือ​ทดสอบ​ไม่​ได้​เลย เพราะ​ต้อง​พึ่ง UI framework จริง (ต้อง​เปิด​หน้าต่าง, ต้อง render, ต้อง​มี event loop) การ​เขียน unit test ให้ logic เหล่า​นี้​จึง​กลาย​เป็น​ภาระ​หนัก​หรือ​ถูก​ข้าม​ไป​เลย

MVP ดัดแปลง​มา​จาก MVC โดย​ความ​ต่าง​เชิง​โครงสร้าง​ที่​สำคัญ​ที่สุด​คือ Presenter ถือ reference ไป​ยัง View ผ่าน interface (ไม่ใช่​ผ่าน concrete UI class) แนวคิด​นี้​มี​ต้น​กำเนิด​ที่ Taligent ใน​ช่วง​ต้น​ทศวรรษ 1990 (โครงการ​ร่วม​ของ Apple, IBM และ Hewlett-Packard สำหรับ​สภาพ​แวดล้อม CommonPoint บน C++) ถูก​เผยแพร่​ต่อ​โดย Mike Potel และ​ภายหลัง​ถูก​ขัดเกลา​อย่าง​ละเอียด​โดย​ทีม Dolphin Smalltalk ใน​เอกสาร “Twisting the Triad” ก่อน​ที่ Martin Fowler จะ​วิเคราะห์​และ​แยก​ย่อย​มัน​ออก​เป็น​สอง​รูปแบบ​ย่อย​ใน “GUI Architectures” — Passive View และ Supervising Controller

หัวใจ​ของ​แนวคิด​คือ: ดึง presentation logic ทั้งหมด ออก​จาก view แล้ว​ย้าย​ไป​ไว้​ที่ presenter ที่​เป็น plain object ธรรมดา ไม่​ผูก​กับ UI framework ใด ๆ เมื่อ view เหลือ​แค่ property/event ที่​ประกาศ​ผ่าน interface (IView) presenter ก็​สามารถ​ถูก​ทดสอบ​ด้วย mock/fake ของ view นั้น​ได้​อย่าง​สมบูรณ์ โดย​ไม่​ต้อง​เปิด​หน้าต่าง​จริง​เลย​สัก​ครั้ง​เดียว

classDiagram
    class IOrderView {
      <<interface>>
      +CustomerName string
      +OrderTotal string
      +ShowError string
      +LoadClicked event
      +SaveClicked event
    }
    class OrderView {
      -presenter OrderPresenter
      +CustomerName string
      +OrderTotal string
      +ShowError string
    }
    class OrderPresenter {
      -view IOrderView
      -repository IOrderRepository
      +OnLoad()
      +OnSave()
    }
    class IOrderRepository {
      <<interface>>
      +GetById(id) Order
      +Save(order)
    }
    class Order {
      +CustomerName string
      +Total decimal
    }
    OrderView ..|> IOrderView
    OrderPresenter --> IOrderView
    OrderPresenter --> IOrderRepository
    OrderPresenter --> Order

สังเกต​ทิศทาง​ของ​ลูกศร: OrderPresenter พึ่งพา IOrderView (interface) ไม่ใช่ OrderView (concrete class) ส่วน OrderView เป็น​ฝ่าย implement interface นั้น​และ​ถือ reference กลับ​ไป​ยัง presenter เพื่อ​ส่ง​ต่อ event ของ​ผู้​ใช้ ความ​สัมพันธ์​นี้​ทำให้ presenter ไม่รู้จัก UI framework เลย​แม้แต่​น้อย — มัน​เห็น​แค่ property string กับ event ธรรมดา

Fowler แยก MVP ออก​เป็น​สอง​แบบ​ตาม​ระดับ​ความ “โง่” ของ view ที่​ยอม​ให้:

  • Passive View — view ถูก​ทำให้ passive ที่สุด ไม่มี​แม้แต่​การ binding กับ model โดยตรง ทุก​อย่าง​ต้อง​ผ่าน presenter รวม​ถึง​การ​เติม​ข้อมูล​ลง widget presenter จึง​ต้อง​เรียก setter ของ view ทุก property ที่​เปลี่ยน (verbose กว่า แต่​ทดสอบ​ได้ 100%)
  • Supervising Controller — view ยัง​ได้​รับ​อนุญาต​ให้​ผูก (bind) กับ model โดยตรง​สำหรับ logic ง่าย ๆ (เช่น data binding ธรรมดา) ส่วน presenter จะ​เข้า​มา​จัดการ​เฉพาะ logic ที่​ซับซ้อน​หรือ​มี​เงื่อนไข — คล้าย​เป็น​จุด​กึ่งกลาง​ระหว่าง MVC กับ Passive View

ผู้​เล่น​หลัก​สาม​ฝ่าย​มี​บทบาท​ดังนี้:

  • View — เป็น passive interface ล้วน ๆ เปิดเผย​เฉพาะ property (สำหรับ​ตั้ง​ค่าที่​แสดง​บน​จอ) และ event (สำหรับ​แจ้ง user gesture เช่น​คลิก​ปุ่ม) ไม่มี logic การ​ตัดสิน​ใจ​ใด ๆ อยู่​ใน​นี้​เลย
  • Presenter — รับ event จาก view, เรียก model/repository เพื่อ​ดึง​หรือ​บันทึก​ข้อมูล, แปลง (format) ข้อมูล​ให้​พร้อม​แสดง​ผล แล้ว “สั่ง” view ผ่าน interface ให้ set ค่า presenter จึง​เป็น​ที่​รวม​ของ presentation logic ทั้งหมด
  • Model — domain object หรือ service ที่​ไม่รู้จัก UI เลย (repository, entity, business service ทั่วไป)

ลำดับ​เหตุการณ์​ทั่วไป​เมื่อ​ผู้​ใช้​กด​ปุ่ม “โหลด​ข้อมูล”:

sequenceDiagram
    participant U as ผู้ใช้
    participant V as OrderView
    participant P as OrderPresenter
    participant R as IOrderRepository

    U->>V: คลิกปุ่ม Load
    V->>P: raise LoadClicked event
    P->>R: GetById(id)
    R-->>P: Order
    P->>V: set CustomerName, OrderTotal
    V-->>U: จอแสดงข้อมูลใหม่

จุด​สำคัญ​คือ view เพียง “ส่ง​สัญญาณ” event ออก​ไป แต่​ไม่รู้​เลย​ว่า presenter จะ​ทำ​อะไร​ต่อ — และ presenter เพียง “สั่ง” view ผ่าน interface โดย​ไม่รู้​เลย​ว่า​เบื้องหลัง view เป็น WinForms, WPF, เว็บ หรือ mock ใน test นี่​คือ​กลไก​ที่​ทำให้​ทั้ง​สอง​ฝั่ง​แยก​จาก​กัน​อย่าง​สมบูรณ์ (decoupled) ตาม Dependency Inversion Principle

using System.Globalization;
// Model — domain object ที่ไม่รู้จัก UI เลย
public class Order
{
public int Id { get; set; }
public string CustomerName { get; set; } = "";
public decimal Total { get; set; }
}
public interface IOrderRepository
{
Order? GetById(int id);
void Save(Order order);
}
// View interface — สัญญาที่ presenter พึ่งพา (mock ได้ใน test)
public interface IOrderView
{
string CustomerName { get; set; }
string OrderTotal { get; set; }
string ErrorMessage { set; }
event Action<int> LoadRequested;
event Action SaveRequested;
}
// Presenter — ที่รวมของ presentation logic ทั้งหมด ทดสอบได้โดยไม่ต้องมี UI จริง
public class OrderPresenter
{
private readonly IOrderView _view;
private readonly IOrderRepository _repository;
private Order? _current;
public OrderPresenter(IOrderView view, IOrderRepository repository)
{
_view = view;
_repository = repository;
// ผูก event ของ view เข้ากับ handler — presenter เป็นฝ่าย "ควบคุม"
_view.LoadRequested += OnLoadRequested;
_view.SaveRequested += OnSaveRequested;
}
private void OnLoadRequested(int orderId)
{
var order = _repository.GetById(orderId);
if (order is null)
{
_view.ErrorMessage = $"ไม่พบคำสั่งซื้อหมายเลข {orderId}";
return;
}
_current = order;
// presenter จัดการ formatting ทั้งหมด view แค่โชว์ string ที่ได้รับ
_view.CustomerName = order.CustomerName;
_view.OrderTotal = order.Total.ToString("C", new CultureInfo("th-TH"));
}
private void OnSaveRequested()
{
if (_current is null)
{
_view.ErrorMessage = "กรุณาโหลดคำสั่งซื้อก่อนบันทึก";
return;
}
_current.CustomerName = _view.CustomerName;
_repository.Save(_current);
}
}

ทดสอบ presenter โดย​ไม่​ต้อง​มี UI จริง​เลย​สัก​ครั้ง​เดียว ด้วย​การ​เขียน fake/stub ของ IOrderView:

using System.Collections.Generic;
public class FakeOrderView : IOrderView
{
public string CustomerName { get; set; } = "";
public string OrderTotal { get; set; } = "";
public string ErrorMessage { set { LastError = value; } }
public string? LastError { get; private set; }
public event Action<int>? LoadRequested;
public event Action? SaveRequested;
public void SimulateLoad(int id) => LoadRequested?.Invoke(id);
}
public class InMemoryOrderRepository : IOrderRepository
{
private readonly Dictionary<int, Order> _orders = new();
public Order? GetById(int id) => _orders.TryGetValue(id, out var order) ? order : null;
public void Save(Order order) => _orders[order.Id] = order;
}
[Fact]
public void Load_ที่มีคำสั่งซื้อจริง_ต้องแสดงชื่อลูกค้าและยอดรวม()
{
var repo = new InMemoryOrderRepository();
repo.Save(new Order { Id = 1, CustomerName = "สมชาย", Total = 250 });
var view = new FakeOrderView();
var presenter = new OrderPresenter(view, repo);
view.SimulateLoad(1);
Assert.Equal("สมชาย", view.CustomerName);
Assert.Equal("฿250.00", view.OrderTotal);
}
  • ต้องการ​ทดสอบ presentation logic ด้วย unit test โดย​ไม่​พึ่ง UI framework หรือ test runner แบบ UI automation
  • ทำงาน​กับ UI framework แบบ event-driven ที่​ไม่มี data binding ที่​ทรง​พลัง (WinForms แบบ​เก่า, บาง framework บน​มือถือ/embedded)
  • ต้องการ​รองรับ​หลาย view implementation (เช่น desktop กับ web) ที่​ใช้ presenter ตัว​เดียวกัน ผ่าน​การ implement interface เดียวกัน
  • ทีม​ต้องการ​แยก​ความ​รับผิดชอบ​ชัดเจน​ระหว่าง​คน​ที่​ออกแบบ UI (implement view) กับ​คน​ที่​เขียน business/presentation logic (เขียน presenter)
  • มี legacy codebase ที่ logic ปน​อยู่​ใน code-behind และ​ต้องการ​ค่อย ๆ แยก​ออก​มา​ให้​ทดสอบ​ได้ (มัก​เริ่ม​จาก Supervising Controller แล้ว​ค่อย​ขยับ​ไป Passive View)
  • UI framework มี two-way data binding ที่​ดี​อยู่​แล้ว (เช่น WPF, Angular, Vue) — MVVM มัก​เหมาะ​กว่า​เพราะ​ใช้ binding แทน​การ set property ที​ละ​ตัว​ด้วย​มือ
  • application เล็ก​มาก มี screen เดียว​หรือ2 logic น้อย — ต้นทุน​ของ​การ​สร้าง interface และ presenter แยก​ต่างหาก​อาจ​ไม่​คุ้ม (YAGNI)
  • ทีม​ทำงาน​กับ web framework สมัย​ใหม่​ที่​มี pattern ของ​ตัวเอง​อยู่​แล้ว (เช่น component-based UI) การ​ฝืน​ใช้ MVP อาจ​ขัด​กับ convention ของ framework
  • ต้องการ synchronize state ระหว่าง​หลาย view พร้อม​กัน​แบบ real-time ซับซ้อน — MVVM หรือ state-management library มัก​ออกแบบ​มา​สำหรับ​เคส​นี้​โดย​เฉพาะ
ด้านรายละเอียด
ข้อดีPresentation logic ทดสอบ​ได้​เต็ม​ที่​ด้วย unit test ธรรมดา ไม่​ต้อง​พึ่ง UI
ข้อดีView กับ presenter แยก​จาก​กัน​ชัดเจน​ตาม Interface Segregation — สลับ view implementation ได้​โดย​ไม่​แตะ presenter
ข้อดีลด logic ที่​ซ้ำซ้อน​หรือ​กระจัดกระจาย​ใน​หลาย code-behind เพราะ​รวม​ศูนย์​ไว้​ที่ presenter
ข้อ​เสียต้อง set ทุก property ผ่าน interface ด้วย​มือ (โดย​เฉพาะ Passive View) ทำให้ boilerplate เยอะ​ขึ้น
ข้อ​เสียถ้า UI framework มี binding ที่​ดี​อยู่​แล้ว MVP จะ​ดู​ซ้ำซ้อน​เมื่อ​เทียบ​กับ MVVM
ข้อ​เสียPresenter อาจ​บวม​กลาย​เป็น God Class ถ้า​ไม่​ระวัง​แยก responsibility ให้​ดี