บทความนี้ อ้างอิงแนวคิดจาก 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....
}
}
จากตัวอย่าง 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
}
}
🐭 เข้าเรื่อง 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
}
จบแล้ว!? จาก 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
}
}
วิธีการแก้อันนี้ก็คือ ตัว MySQLUserRepository
จะต้อง Explicit ประกาศการ implements ของ UserRepository
ด้วยถึงสามารถ Compile ผ่าน
class MySQLUserRepository implements UserRepository {
public void Save(User user) { }
}
แต่สำหรับ 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 ได้
}
เมื่อมีความยืดหยุ่นขนาดนี้ ทำให้ 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)
}
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 { }
package main // main.go
func main() {
var userrepo = repo.UserRepository{ }
var loginuc = auth.LoginUseCase{
UserFinder: userrepo, // สามารถใช้ได้เพราะ มี Find()
}
}
จาก Snippet จะพบว่า สิ่งที่ LoginUseCase
ต้องการนั้นไม่ใช่ UserRepository
แต่เป็นอะไรก็ได้ที่มี
Find(context.Context, id string) (*entity.User, error)
ตามที่กล่าวไว้ใน 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 })
}
ปัญหานี้ เกิดจาก 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 })
}
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 { }
เนื่องจากการประกาศ interface จะทำให้รู้ overview คร่าวๆของ สิ่งที่ package นี้ provide ออกมา ทำให้การ maintain struct ใหญ่ๆจะง่ายขึ้น ผู้เขียน บางคร้ังมีการประกาศ interface ไว้เพื่อควบคุม signature ของ concrete ที่ package นี้รับผิดชอบอยู่
แต่ในตัวอย่างนั้น UserRepository
จะแทบไม่ได้ถูกใช้งานโดย package อื่นเนื่องจาก ขนาดของ interface นี้จะเริ่มใหญ่ขึ้นตาม concrete ที่ implement ซึ่งจะขัดกับหลักการ interface segregation และทำให้ maintain ยาก
สรุป
อยู่ข้างบนสุดแล้ว หัวข้อ TR:DR
ขอให้มีความสุขกับการเขียนโค้ด!
Top comments (0)