SOLID หลักการพื้นฐานที่โปรแกรมเมอร์ควรรู้ (ตอนที่ 1 – SRP)

หลายๆ ครั้งระหว่างที่เขียนโค้ดอยู่นั้นเราอาจจะนึกถามตัวเองบ้างว่า “ทำไม Method นี้ยาวจัง สร้าง Method ใหม่ดีมั้ย?“,  “เอ๊ะ! หรือแยก 5 บรรทัดนี้ออกไปอยู่อีก Class ดี?” แต่เมื่อนั่งคิดไปมาอยู่ซักพักก็จบลงด้วยการปล่อยให้โค้ดเป็นไปตามเดิมเพราะไม่รู้ว่าควรจะเริ่มจากตรงไหนดี ในความเป็นจริงแล้วไม่มีโค้ดไหนที่ถูกต้องไปซะทั้งหมดหรือผิดโดยสิ้นเชิงถ้าหากโค้ดนั้นสามารถทำงานตามความต้องการ (Requirement) ได้อย่างถูกต้อง อย่างไรก็แล้วแต่การเขียนโค้ดให้มันทำงานได้ครั้งแรกมันช่างง่ายดายและรวดเร็ว แต่มันมักจะเป็นฝันร้ายของพวกเราชาวโปรแกรมเมอร์เสมอเมื่อต้องกลับมาแก้ไขหรือเพิ่มเติมสิ่งที่เราได้ทำลงไปแล้วหรือจะเป็นของคนอื่นก็ตาม ดังนั้นโค้ดที่เราเขียนออกมาตั้งแต่แรกนั้นควรจะเป็นอะไรที่เข้าใจและแก้ไขได้ง่ายทั้งต่อตัวเองและผู้อื่น หรือถ้ามองไปถึงเรื่อง TDD ด้วยแล้วโค้ดนั้นก็ควรจะเป็นโค้ดที่ง่ายต่อการทดสอบ (Testing) อีกด้วย และหลักการหนึ่งที่อาจจะช่วยเราในการตัดสินใจในการออกแบบโครงสร้างของ Appplication ได้นั้นก็คือหลักการออกแบบ Software พื้นฐานที่ชื่อว่า SOLID Principles นั่นเอง

SOLID Principles คืออะไร?

SOLID (S.O.L.I.D.) เป็นตัวย่อของหลักการพื้นฐาน 5 ข้อซึ่งถูกคิดและเผยแพร่โดย “Robert C. Martin” หรือเราอาจจะคุ้นหูกับชื่อที่ว่า “Uncle Bob” ซึ่งกระกอบไปด้วย

  1. Single Responsibility Principle (SRP)
  2. Open Closed Principle (OCP)
  3. Liskov Substitution (LSP)
  4. Interface Segregation Principle (ISP)
  5. Dependency Inversion (DIP)

โดยทั้ง 5 หลักการนี้ไม่ได้มีกฏตายตัวที่เราจำเป็นที่จะต้องตามทั้ง 5 หลักการเสมอในทุกๆ ครั้งที่เราเขียนโค้ดเนื่องจากในแต่ละ Application ก็มีความแตกต่างกันออกไปเช่น ในเรื่องของขนาด, Requirement, ความซับซ้อน และอื่นๆ ดังนั้นสิ่งที่เราควรทำก็คือทำความเข้าใจถึงหลักการทั้ง 5 ข้อให้ดีเพื่อที่เราจะได้เลือกหลักการแต่ละข้อมาใช้ได้อย่างถูกต้องในสถานการณ์ต่างๆ

ภาษาและ Framework ที่ใช้

ภาษาที่ใช้สำหรับบทความนี้จะเป็น PHP  และใช้ Laravel 5.1 โค้ดตัวอย่างทั้งหมดผมจะพยายามใช้ฟังก์ชันเดิมของ PHP เพื่อให้ผู้ที่ไม่คุ้นเคยกับ Laravel มาก่อนเข้าใจได้ง่าย และสำหรับผู้ที่เคยใช้ Laravel มาบ้างแล้วแล้วต้องขอบอกไว้ว่าบทความนี้จะข้ามเรื่อง Dependency Injection และ IOC ไปนะครับ

ส่วนสำหรับผู้ที่เขียนภาษาอื่นๆ ที่ไม่ใช่ PHP ก็ไม่ต้องเป็นกังวลไปเพราะบทความนี้จะไม่ได้เจาะลึกไปถึงฟังก์ชันใดๆ ของ PHP จึงสามารถอ่านและนำไปประยุกต์ใช้ได้กับภาษาอื่นๆ เช่นกัน

Single Responsibility (SRP)

หลักการ SRP กล่าวไว้ว่า

 A class should have one, and only one, reason to change

ซึ่งก็หมายความว่า

Class ควรจะมีเหตุผลเพียงหนึ่งเดียวเท่านั้นที่จะเปลี่ยน

หรืออีกนัยยะหนึ่งอาจจะพูดได้ว่า การออกแบบ Class เราควรจะกำหนดขอบเขตการทำงานและความรับผิดชอบของ Class ให้ชัดเจนที่สุดเท่าที่จะทำได้

มาดู Class ตัวอย่างกันครับ

// PaypalBiller

ตามชื่อของ Class จะทราบได้ว่า PaypalBiller  จะเป็น Class ไว้สำหรับเรียกเก็บเงินจากลูกค้าโดยใช้ Service ของ Paypal

class PaypalBiller
{
/**
* @param int $userId
* @param float $amount
*/
public function bill($userId, $amount)
{
// เรียกเก็บเงินจากลูกค้า
echo ‘Bill the customer’;
}
}

 

 

 

// Order

ในความเป็นจริงแล้ว Order  อาจจะเป็น Model ตาม Framework ที่เราใช้ เช่น Eloquent, Doctrine, Entity Framework หรือ Hinernate แต่ในตัวอย่างนี้เพื่อที่จะไม่เจาะจงกับ Framework ใด Framework หนึ่งให้มากเกินไป Class นี้จะทำหน้าที่แค่เก็บ Attribute ต่างๆ ของตัวสินค้า (Order) เท่านั้น

class Order
{
/**
* @var int $id
*/
public $id;

/**
* @var float price
*/
public $price;

/**
* @var string $category
*/
public $category;
}

 

 

// OrderProcessor (v.1)

Class นี้ทำหน้าที่จัดการ การสั่งซื้อสินค้า (Order) ที่เข้ามาจากลูกค้า ซึ่งจากโค้ดจะมีหน้าที่ คิดราคาสินค้าที่หักส่วนลดแล้ว, เรียกเก็บเงินจากลูกค้า, Log การสั่งซื้อสินค้า

class OrderProcessor
{
    /**
     * @var PaypalBiller $biller
     */
    protected $biller;
    /**
     * OrderProcessor constructor.
     * @param PaypalBiller $biller
     */
    public function __construct(PaypalBiller $biller)
    {
        $this->biller = $biller;
    }
    /**
     * @param Order $order
     * @param int $userId
     */
    public function process(Order $order, $userId)
    {
        // ลดราคาสินค้าตามหมวดหมู่
        $percentage = $this->getCategoryDiscountPercentage($order);
        // สมมุติว่าค่าที่ได้รับมาคือ 10% ดังนั้นคือ 0.1
        $finalPrice = $order->price * (1 $percentage);
        // เรียกเก็บเงิน
        $this->biller->bill($userId, $finalPrice);
        // บันทึกการสั่งซื้อ
        $this->logOrder($userId, $order, $finalPrice);
    }
    /**
     * @param Order $order
     * @return float
     */
    protected function getCategoryDiscountPercentage(Order $order)
    {
        // เรียกข้อมูลส่วนลดมาจาก Database
        return DB::table(‘discounts’)
            ->where(‘category’, ‘=’, $order->category)
            ->first()
            ->percentage;
    }
    /**
     * @param int $userId
     * @param Order $order
     * @param float $amountPaid
     */
    protected function logOrder($userId, Order $order, $amountPaid)
    {
        DB::table(‘orders’)->insert([
            ‘user_id’ => $userId,
            ‘order_id’ => $order->id,
            ‘final_price’ => $amountPaid
        ]);
    }
}

จาก Code ด้านบนเห็นได้ว่า Method getCategoryDiscountPercentage  ทำหน้าที่สร้าง Query เพื่อเรียกข้อมูลส่วนลดจาก Database ด้วย ซึ่งจริงๆ แล้วจากหน้าที่ของ  OrderProcessor  ตัว Class ไม่จำเป็นที่ต้องทราบถึงวิธีการเรียกข้อมูลจาก Database ดังนั้นสิ่งที่เราควรทำคือแยกความรับผิดชอบในการดึงข้อมูลจาก Database ไปอีก Class นึงตามตัวอย่างด้านล่าง

// OrderRepository

class OrderRepository{
    /**
     * @param Order $order
     * @return float
     */
    public function getCategoryDiscountPercentage(Order $order)
    {
        // เรียกข้อมูลส่วนลดมาจาก Database
        return DB::table(‘category_discounts’)
            ->where(‘category’, ‘=’, $order->category)
            ->first()
            ->percentage;
    }
    /**
     * @param int $userId
     * @param Order $order
     * @param float $amountPaid
     */
    public function logOrder($userId, Order $order, $amountPaid)
    {
        DB::table(‘orders’)->insert([
            ‘user_id’ => $userId,
            ‘order_id’ => $order->id,
            ‘final_price’ => $amountPaid
        ]);
    }
    
}

// OrderProcessor (v.2)

หลังจากจากเรามี Class ที่รับผิดชอบเฉพาะการเรียกข้อมูลแล้ว (Data Persistence Layer) เราสามารถปรับปรุง OrderProcessor  ได้ตามโค้ดด้านล่าง ซึ่งจะเห็นว่า  OrderRepository  ถูกส่งผ่านทาง Constructor ทำให้การ Mock ตัว Class อื่นที่เกี่ยวข้องกับ OrderProcessor (Class Dependencies) ทำได้ง่ายเมื่อเขียน Unit Test

class OrderProcessor
{

/**
* @var PaypalBiller $biller
*/
protected $biller;

/**
* @var OrderRepository $orderRepo
*/
protected $orderRepo;

/**
* OrderProcessor constructor.
* @param PaypalBiller $biller
* @param OrderRepository $orderRepo
*/
public function __construct(PaypalBiller $biller, OrderRepository $orderRepo)
{
$this->biller = $biller;
$this->orderRepo = $orderRepo;
}

/**
* @param Order $order
* @param int $userId
*/
public function process(Order $order, $userId)
{
// ลดราคาสินค้าตามหมวดหมู่
$percentage = $this->orderRepo->getCategoryDiscountPercentage($order);

// สมมุติว่าค่าที่ได้รับมาคือ 10% ดังนั้นคือ 0.1
$finalPrice = $order->price * (1 – $percentage);

// เรียกเก็บเงิน
$this->biller->bill($userId, $finalPrice);

// บันทึกการสั่งซื้อ
$this->orderRepo->logOrder($userId, $order, $finalPrice);
}
}

 

 

 

จะเห็นได้ว่าตอนนี้ OrderRepository  มีหน้าที่เพียงอย่างเดียวคือการเรียกข้อมูลจาก Database ดังนั้นหากโครงสร้างของ Database เกิดปลี่ยนแปลงขึ้นในอนาคต เราไม่จำเป็นที่จะต้องไปแก้ไขโค้ดใน OrderProcessor  แต่ให้มาทำที่ OrderRepository ซึ่งมีหน้าที่โดยตรงในการต่อ Database และนั่นก็หมายความว่าเราได้ตามหลักการของ SRP แล้ว

มุมมอง TDD

ถ้าเปรียบเทียบ Test Scenario คร่าวๆ ของ OrderProcessor  ก่อน (v.1) และหลัง (v.2) จะพบว่าสำหรับOrderProcessor  v.1 การเขียน Test ต้องเขียนให้ครอบคลุมถึงการทดสอบผลลัพธ์ที่ได้จาก Database ด้วย แต่สำหรับ OrderProcessor  v.2 นั้นเราสามารถ Mock ตัว OrderRepository  รวมถึงค่าที่จะ Return ได้และนั่นก็หมายความว่า Test Class ของเราทำหน้าที่ทดสอบเฉพาะการทำงานของ OrderProcessor  เพียงอย่างเดียวซึ่งก็จะเป็นไปตามหลักการของ SRP เช่นกัน

บทสรุป

สิ่งที่น่าสังเกตก็คือ จำนวนบรรทัดของ OrderProcessor  v.2 ที่ลดลงอย่างเห็นได้ชัด แต่จริงๆ แล้วสิ่งนั้นไม่ใช่เหตุผลหลักที่เราต้องการจาก SRP แต่สิ่งสำคัญที่เราควรคิดเสมอเมื่อทำตามหลักการของ SRP คือการ Scope การทำงานของ Class ให้มีหน้าที่เพียงอย่างเดียวและมีความรับผิดชอบที่ชัดเจน โดยผลที่ตามมาคือ Class หรือ Method จะมีการทำงานไปในทิศทางเดียวกัน อีกทั้งทำให้ตัว Class ของเราเป็นไปตามหลักการออกแบบ Class ที่ดีอีก 2 ข้อคือ High Cohesion และ Low Coupling ด้วย

สำหรับผู้ที่มีข้อสงสัยหรือมีคำแนะนำใดๆ สามารถทิ้งข้อความไว้ได้ที่ Comment ด้านล่างเลยนะครับ 😀

อ้างอิง

  1. https://leanpub.com/laravel
  2. https://www.ikq.me/2016/02/solid-principles-part-1-srp/