loading...
Cover image for ใช้งาน Interface แบบพิลึกของ Golang

ใช้งาน Interface แบบพิลึกของ Golang

zapkub profile image Rungsikorn Rungsikavanich ・3 min read

บทความนี้ อ้างอิงแนวคิดจาก Effective Go และ Go CodeReviews เป็นหลัก

บทความนี้ต้องการความเข้าใจเกี่ยวกับสาเหตุ หรือเหตุผลของการใช้ Interface ในการเขียนโปรแกรม เบื้องต้นก่อน

⚡️ ชิงสรุปก่อนเลย TL:DR

Golang มี interface ที่ทำงานได้ยืดหยุ่นกว่า interface ภาษา OOP ทั่วไป โดยไม่ต้องมีการประกาศ implements และ Golang จะไม่นิยมประกาศ interface ไว้ที่ Library ต้นทาง แต่จะประกาศ Interface ไว้ที่ implementation ที่ใช้งานแทน โดยวิธีนี้ ทำให้การเขียน code สะอาดขึ้น และ Unit test ได้ง่ายขึ้น

👶 เรื่อง Interface คร่าวๆ

พูดถึงเรื่อง Interface กันหน่อยดีกว่า อย่างที่เรารู้กันว่า Interface กับภาษา Strong type เป็นของคู่กันมาตั้งแต่ไหนแต่ไร เพื่อให้ Programmer สามารถกำหนด Abstraction ของส่วนประกอบต่างๆภายในโปรเจคได้

ภาษา OOP อย่างที่เราคุ้นเคยกัน เช่น Java หรือ C# เนี้ย Interface แทบจะเป็นหัวใจหลักของการออกแบบระบบที่ซับซ้อน แทบจะขาดกันไม่ได้เลยทีเดียว เพราะหลายๆ Design Pattern ต้องการใช้ความสามารถของ Interface เพื่อทำให้บรรลุเป้าหมายได้

Interface ใน OOP 🐣

ก่อนที่จะเข้าเรื่อง Interface ของ Golang เรามาดูหน้าตาของ Interface ทั่วไปที่ใช้กันใน OOP language ก่อนซิ


public interface UserRepository { 
    void save(User user);
}

public class MySQLUserRepository implements UserRepository {
    public void save(User user) { 
         // doing convert User entity to SQL statement and
         // execute SQL statement
    }
}

public class BufferUserRepository implements UserRepository{
    public void save(User user) { 
         // doing in-memory buffering user stuff....
    }
}

Enter fullscreen mode Exit fullscreen mode

จากตัวอย่าง snippet ด้านบน จะทำให้เราสามารถเลือกใช้ UserRepository อันใดอันนึงได้ โดยไม่ต้องยึดติดกับ implementation ของ class ใด class หนึ่ง โดยมีประโยชน์ในการทำ Unit test หรือการ Mocking test ใน package อื่นๆที่มี UserRepository เป็น dependency

ตัวอย่าง เช่น


// 🙅‍♂️ อย่าหาทำ การเพิ่ม Dependency แบบ Concrete ( ไม่ใช่ทุกกรณี )
public class LoginUseCase {
    public LoginUseCase(MySQLUserRepository userRepository){
        // do initialize stuff
    }
}

// 🙆‍♂️ ควรทำแบบนี้ มี dependency เป็น interface หรือ abstract class
public class LoginUseCase {
    public LoginUseCase(UserRepository userRepository){
        // do initialize stuff
    }
}
Enter fullscreen mode Exit fullscreen mode

🐭 เข้าเรื่อง Interface ของ Golang

ใน Golang นั้นมันดันไม่ใช้ 100% OOP ซะทีเดียว อย่างเช่นภายใน Golang เราไม่มี Class และไม่มีการ ประกาศ Extends หรือ Implements

ผู้เขียนปวดกะบาลมาก สมัยเริ่มศึกษา Golang ใหม่ๆ

ถึงแม้ว่า OOP concept จะไม่ได้ถูกยกมาเต็มๆภายใน Golang แต่หลายๆ Idea ของ OOP ก็เป็นประโยชน์มากๆในการพัฒนา Software ด้วยภาษา Golang

เรามาเริ่มกันที่สิ่งที่ OOP มีแต่ Golang 🚫 ไม่มีกันก่อนซิ

  • ไม่มี abstract class
  • ไม่มี class ( มี struct แทนซึ่งเกือบๆจะเหมือน class แหล่ะแต่ไม่มี constructor )
  • ไม่มี Extends
  • ไม่มี Implements
  • interface ไม่มี Attribute ( Member Variable ) มีแต่ Method
  • ไม่มี Generic Type ( ปัจจุบัน version 1.14 ยังไม่มี จะมีใน 1.18 )

✅ แล้วสิ่งที่เหมือนกันหล่ะ

  • มี interface ( ก็แน่ซิวะ )
  • มีการทำ Encapsulation

อย่างที่รู้กันว่า Golang เป็นภาษาที่โง่เง่าแทบจะใกล้เคียง C++ โดยที่ Feature ต่างๆที่คุ้นเคยใน Modern programming langauge นั้นมันแทบจะไม่เอามาเลย เพราะฉะนั้นการใช้ interface ใน Golang มันก็เลยแสนจะเรียบง่ายแบบนี้

package main
type User struct { }
type UserRepository interface {
     Save(user User) error
}

type MySQLUserRepository struct { }
func (m *MySQLUserRepository) Save(user User) error { 
    // doing convert User entity to SQL statement and
    // execute SQL statement
}

Enter fullscreen mode Exit fullscreen mode

จบแล้ว!? จาก snippet ถ้าผู้อ่านมาจากพื้นฐานการใช้ interface ภาษาอื่นก็อาจจะ มึนๆว่า

แล้วมันไป implements กันตอนไหนวะ?

ความพิเศษของ Interface ใน Golang ถูกเขียนไว้ในบทความ Effective Go ว่า

a way to specify the behavior of an object: if something can do this, then it can be used here.

ห้ะ อะไรทำตามนี้ได้ ก็เอามาใส่ตรงนี้ได้ 🤨

if something can do this, then it can be used here.

แน่นวลว่า ในภาษา Strong Type OOP อย่าง Java ไม่อนุญาติให้เรา assign class ที่ไม่ได้ implements interface ที่ถูกต้องเข้าไปใน Variable ของ interface นั้น

ถึงแม้ว่ามันทำงานได้ตรงตาม Behavior ที่ประกาศไว้ใน interface เป้ะๆ ก็ตาม

อย่างเช่น

package com.company;

class User {}

interface UserRepository {
    void Save(User user);
}

class MySQLUserRepository {
    public void Save(User user) { }
}

public class Main {
    public static void main(String[] args) {
        UserRepository userrepo = new MySQLUserRepository();
        // ^^^^ java: incompatible types: com.company.MySQLUserRepository cannot be converted to com.company.UserRepository
    }
}
Enter fullscreen mode Exit fullscreen mode

วิธีการแก้อันนี้ก็คือ ตัว MySQLUserRepository จะต้อง Explicit ประกาศการ implements ของ UserRepository ด้วยถึงสามารถ Compile ผ่าน

class MySQLUserRepository implements UserRepository {
    public void Save(User user) { }
}
Enter fullscreen mode Exit fullscreen mode

แต่สำหรับ Golang ไม่เป็นแบบนั้น เนื่องจากอย่างที่บอกไว้ใน Effective Go ว่าอะไรทำงานได้ตรงตาม interface ก็เอามาใส่ได้เลยสิวะ

ผลลัพธ์ก็คือ ไม่จำเป็นจะต้องมี syntax implements ใดๆใน type declaration หาก type นั้นสามารถทำงานได้ตรงกับที่ interface กล่าวไว้ ถือว่าใช้ได้ สามผ่าน!!!!

package main
type User struct { }
type UserRepository interface {
     Save(user User) error
}

type MySQLUserRepository struct { }
func (m *MySQLUserRepository) Save(user User) error { }

func main() {
    var userrepo UserRepository
    userrepo = &MySQLUserRepository {} 
    // ^^^^^^^ บรรทัดนี้ใช้งานได้เลย ผ่าน เพราะ MySQLUserRepository สามารถ Save(user User) error ได้
}
Enter fullscreen mode Exit fullscreen mode

เมื่อมีความยืดหยุ่นขนาดนี้ ทำให้ interface ใน Golang มันสามารถตีลังกาใช้งานได้หลายแบบ ตามความเหมาะสมของ design pattern หรือ ปัญหาต่างๆที่เราพยายามจะควบคุมอยู่

เนื้อหาต่อไปจะเป็นวิธีการใช้งานส่วนหนึ่งที่ผู้เขียนใช้งานอยู่

1. ประกาศ interface บริเวณที่ต้องการมี Dependency

ตามที่ Go CodeReviewComments ได้กล่าวไว้ในหัวข้อ Interfaces ว่า

Go interfaces generally belong in the package that uses values of the interface type, not the package that implements those values. The implementing package should return concrete (usually pointer or struct) types: that way, new methods can be added to implementations without requiring extensive refactoring.

แปล

Go interface ปกติแล้วจะต้องอยู่ใน package ที่ใช้งาน interface นั้น, ไม่ใช่ package ที่ implements สิ่งนั้นๆ. package ที่ implement ควรจะต้อง return concrete types ซึ่งวิธีนี้ จะสามารถทำให้เพิ่ม method ใหม่ๆได้ง่ายโดยไม่ต้องใช้เวลาในการ refactoring มาก.

หัวข้อนี้น่าจะเป็น หัวใจของการใช้ interface ของ Golang เลยทีเดียว ในปกติแล้ว ภาษา OOP จะมี interface ควบคู่กับ implementation ตลอดเพื่อให้รู้ว่า package นั้นจะมีอะไรให้ใช้งานบ้าง แต่ใน Golang จะพยายามทำกลับด้านกัน

package auth // login.go
type UserFinder interface { // นี่คือส่ิงที่เราต้องการ มีแค่ Find ก็พอแล้ว
    Find(context.Context, id string) (*entity.User, error)
}
type LoginUseCase struct { 
    Userfinder UserFinder
}
func (l *LoginUseCase) Login(ctx context.Context, id, pass string) (string,error) { 
    ...
    l.userfinder.Find(ctx, id)
} 
Enter fullscreen mode Exit fullscreen mode
package repo // user.go
type UserRepository struct {} // UserRepository เป็น implementation เต็มๆ
func (u *UserRepository) Find(context.Context, id string) (*entity.User, error) { }
func (u *UserRepository) Save(context.Context, user *entity.User) error { }
Enter fullscreen mode Exit fullscreen mode
package main // main.go

func main() {
   var userrepo = repo.UserRepository{ } 
   var loginuc = auth.LoginUseCase{ 
        UserFinder: userrepo, // สามารถใช้ได้เพราะ มี Find()
   }
}

Enter fullscreen mode Exit fullscreen mode

จาก Snippet จะพบว่า สิ่งที่ LoginUseCase ต้องการนั้นไม่ใช่ UserRepository แต่เป็นอะไรก็ได้ที่มี

   Find(context.Context, id string) (*entity.User, error)
Enter fullscreen mode Exit fullscreen mode

ตามที่กล่าวไว้ใน UserFinder

และเนื่องจาก UserRepository นั้นมี method นี้อยู่ จีึงสามารถ assign ให้กับ LoginUseCase ได้

ดังนั้นสิ่งที่เกิดขึ้นคือ LoginUseCase นั้นไม่จำเป็นต้องรับรู้ว่ามี UserRepository หรือเปล่า โดย LoginUseCase แค่ประกาศสิ่งที่ต้องใช้งานไว้ที่ตัวเอง และรอ package อื่นๆเป็นคนโยนเข้ามาให้

2. ประกาศ Getter interface เพื่อทำ Union Type

จาก การใช้งานข้อ 1. ทำให้เราสามารถรวม 2 Type (หรือมากกว่า) เป็น Type เดียวกันได้ ในกรณีต้องการรับ parameter ให้ได้มากกว่า 1 type ยกตัวอย่างเช่น

package main
type Cat struct { 
    Name string
    Whiskers int32
} 
type Dog struct {
    Name string
    Fangs int32
}

func SpellName(c ???) { fmt.Println(c.Name) }

func main() { 
    SpellName(Cat{ Name: "Happies", Whiskers: 42 })
    SpellName(Dog{ Name: "Howard", Fangs: 4 })
} 

Enter fullscreen mode Exit fullscreen mode

ปัญหานี้ เกิดจาก Golang นั้นไม่มี Attribute ที่เป็น Value ใน interface และไม่สามารถทำ Union Type แบบภาษาอื่นๆได้ ทำให้ต้องมีการทำ Get เพื่อทำให้ 2 types สามารถ compat กันได้ (หรือสามารถทำ type assertion ได้ ซึ่งจะเล่าในบทความอื่น)

package main
type Cat struct { 
    Name string
    Whiskers int32
} 
func (c Cat) GetName() string { return c.Name } 
type Dog struct {
    Name string
    Fangs int32
}
func (d Dog) GetName() string { return d.Name }

func SpellName(c interface{ GetName() string }) { 
     //           ^^^^^ ตรงนี้ bonus การทำ inline interface
     fmt.Println(c.GetName()) 
}

func main() { 
    SpellName(Cat{ Name: "Happies", Whiskers: 42 })
    SpellName(Dog{ Name: "Howard", Fangs: 4 })
} 

Enter fullscreen mode Exit fullscreen mode

3. ประกาศ interface ควบคู่กับ struct ที่ package ไปเลย

ข้อนี้อาจจะขัดใจ Gopher ทั้งหลายอยู่บ้าง เพราะนี่คือการใช้งาน interface แบบ ภาษา OOP ทั่วไปคือการประกาศ interface ไว้ที่ package ต้นทาง เช่น

package repo // user.go

// **ต้องคิดดีๆก่อนเอา interface นี้ไปใช้
type UserRepository interface { 
    Find(context.Context, id string) (*entity.User, error)
    Save(context.Context, user *entity.User) error
}
type SQLUserRepository struct {} // UserRepository เป็น implementation เต็มๆ
func (u *SQLUserRepository) Find(context.Context, id string) (*entity.User, error) { }
func (u *SQLUserRepository) Save(context.Context, user *entity.User) error { }

Enter fullscreen mode Exit fullscreen mode

เนื่องจากการประกาศ interface จะทำให้รู้ overview คร่าวๆของ สิ่งที่ package นี้ provide ออกมา ทำให้การ maintain struct ใหญ่ๆจะง่ายขึ้น ผู้เขียน บางคร้ังมีการประกาศ interface ไว้เพื่อควบคุม signature ของ concrete ที่ package นี้รับผิดชอบอยู่

แต่ในตัวอย่างนั้น UserRepository จะแทบไม่ได้ถูกใช้งานโดย package อื่นเนื่องจาก ขนาดของ interface นี้จะเริ่มใหญ่ขึ้นตาม concrete ที่ implement ซึ่งจะขัดกับหลักการ interface segregation และทำให้ maintain ยาก

สรุป

อยู่ข้างบนสุดแล้ว หัวข้อ TR:DR

ขอให้มีความสุขกับการเขียนโค้ด!

Discussion

pic
Editor guide