Tutorial

Design Python Package ยังไงให้ปัง

By Arnon Puitrakul - 02 พฤศจิกายน 2021 - 1 min read min(s)

Design Python Package ยังไงให้ปัง

เมื่อไม่กี่วันก่อน เราต้องมาเขียน Package บน Python เพื่อเอาไปใช้งานต่อ ในงานนั้น เราก็ลองคิด Pattern ในหลาย ๆ แบบดูว่าเออ แบบไหนมันน่าจะเหมาะกับงานของเรามากกว่ากัน มันมีเยอะมาก ๆ แต่เราเลือกอันที่เราว่าน่าจะได้ใช้บ่อย ๆ มาให้ดูกัน และอาจจะเล่าเพิ่มหน่อยว่า แบบไหนมันน่าจะเหมาะกับงานประเภทไหน เผื่อใครจะเอาไปใช้จะได้ใช้ได้ง่าย ๆ เลย

สร้าง Package ทิพย์กันก่อน

ถ้าจะเอางานจริงมาให้ดูเลยก็แหม่นะ งั้นก่อนเราจะไปลองสร้าง Package ในแต่ละแบบกัน เรามาสร้างไฟล์ Function ใน Package ทิพย์กันก่อน

/src
    /thip_package
        __init__.py
        data_container.py
        data_operation.py

ในตัวอย่างนี้เราจะทำเป็นโปรแกรมง่าย ๆ ในการอ่าน เขียน และ ทำ Math Operation บางอย่างกัน

def load_data (file_path:str) -> list :
    final_result = []
    data_file = open(file_path, 'r')
    
    while (line := data_file.readline()) != '' :
        final_result.append(int(line.rstrip()))
    
    data_file.close()

def write_to_file (input_list:list, destination_file_path:str) -> bool :
    try :
        destination_file = open(destination_file_path, 'w')
    
        for item in input_list :
            destination_file.write(str(item) + '\n')
    
        destination_file.close()
        return True
     except :
        return False
data_container.py

ใน Module แรก เราเขียนเป็น Script ง่าย ๆ ในการอ่านข้อมูลจากไฟล์ โหลดใส่ List และคืนกลับไป กลับกัน ขาเขียน เราก็รับ List เข้ามา และ เขียนกลับลงไปในไฟล์แค่นั้นเลย แค่เราทำเองเท่านั้นมันเลยยาว

def add_number (data_container:list, constant:int) -> list :
	return [item + constant for item in data_container]

def minus_number (data_container:list, constant:int) -> list :
	return [item - constant for item in data_container]

def multiply_number (data_container:list, constant:int) -> list :
	return [item * constant for item in data_container]

def increase_number (data_container:list) -> list :
	return add_number(data_container, 1)
    
def decrease_number (data_container:list) -> list :
	return minus_number(data_container, 1)
data_operation.py

และอีก Module เราก็ทำง่าย ๆ อีกเหมือนกัน คือ เรารับ List พร้อมกับตัวเลขเข้ามา โดยจะมี Function บวก ลบ และ คูณ นอกจากนั้น เรายังมี Function สำหรับการ บวก และ ลบ 1 อีกที เพิ่มขึ้นมาให้ดูเท่ ๆ ไปงั้นแหละ ไม่ได้ทำให้ต่างอะไร

จากนี้แหละ ถ้าเรามี Function แบบนี้ เราจะออกแบบ Module ให้เราสามารถเรียกได้สะดวก และ เข้าใจได้ง่ายได้อย่างไร ซึ่งถ้าใครที่เคยเขียน Module มาก่อน ก็น่าจะรู้แล้วละว่า เรากำหนดลักษณะ การเรียกต่าง ๆ ผ่านไฟล์ที่ชื่อ init นั่นเอง โดยมันจะเป็น File ที่ Python จะเรียก เมื่อเราทำการ Import อะไรบางอย่างมา ในบาง Package ที่เราทำ อาจจะต้องมีการเชื่อมต่อกับ Database เราอาจจะเขียน Connector เป็น Singleton เอาไว้ แล้วให้ Module ภายในเรียกใช้ก็ได้อีกเหมือนกัน แต่ในตัวอย่างวันนี้เราไม่มีอะไรแบบนั้น เราจะโฟกัสกันที่การออกแบบเป็นหลักละกันนะ

1. วาง ๆ กอง ๆ รวม ๆ กันไปเห๊อะ !

เรามาดูกันที่แบบแรกกันก่อน เราขอเรียกว่า วาง ๆ กอง ๆ รวม ๆ กันไปเห๊อะ ! หมายความว่า เราทำให้ทุก Function เลย ถูกเรียกได้จาก Module ของเราโดยตรงเลย ทำให้เวลาเราเขียน Init File มันก็จะได้แบบด้านล่าง

from data_container import *
from data_operation import *
__init__.py

แบบนี้ง่ายมาก ๆ คือ เราก็เรียกทุกอย่างลงมาหมดเลย นั่นทำให้เวลาเราดึง Module ไปใช้งานจริง ๆ เราก็จะเรียก Function อะไรก็ได้เลยที่อยู่ใน Module ของเรา มันก็จะเป็นแบบด้านล่างนี้

import thip_package

my_data = thip_package.read_data('data_file.txt')
my_data = thip_package.increase_number(my_data)

จาก Pattern ด้านบนเราจะเห็นได้เลยว่า ไม่ว่าใน Module ของเราจะมีกี่ร้อย กี่พัน Function เราก็สามารถเรียกมันมาได้อย่างง่ายดายโดยที่เราไม่ต้องแคร์เลยว่า มันจะอยู่ใน Submodule ไหน หรือเรียงยังไง อะไรเรียกใคร เรียกได้ว่าไม่มี Hierarchy หรือ Structure อะไรกันทั้งสิ้น หรือกระทั่งเมื่อเรามีการ Update Function มีการเพิ่มหรือลด เราไม่ต้องไป Maintain อะไรใน File เลย การทำแบบนี้เหมาะมาก ๆ กับ Module ที่มีขนาดเล็ก ไม่กี่สิบ Function เราว่ายังพอได้อยู่

แต่ ๆ มันก็มีข้อเสียเหมือนกัน อย่างแรกคือ เมื่อเราเรียกจาก Top Level เลย คือไม่มี Submodule เลย เมื่อ Function ของเราเยอะขึ้นเรื่อย ๆ การตั้งชื่อ เพื่อให้สื่อความหมายอ่านแล้วเข้าใจได้ง่าย ไม่ใช่เรื่องง่ายเป็นเงาตามตัวเลย นอกจากนั้น การเรียกแบบนี้ นั่นหมายความว่า Logic ในการ Setup เราจะต้องทำเพื่อให้มันรองรับทุก Function เลยนะ ทั้งที่จริง ๆ แล้ว User อาจจะไม่ได้เรียกส่วนนั้นออกมา หรือก็คือ เราโหลดเสียฟรีเลยนั่นเอง ไม่โอเคเท่าไหร่ ดังนั้น เมื่อ Module เราใหญ่ขึ้น วิธีนี้ไม่ใช่เรื่องดีเลยที่จะทำ

เมื่อเราเลือกใช้วิธีนี้ สิ่งที่สำคัญมาก ๆ เลยคือ การตั้งชื่อ เพราะถ้าเราไม่มี Submodule เลย แล้วเราตั้งชื่อไม่ Clear อ่านแล้วต้องคิดต่อ ทำให้เมื่อคนอื่นมาอ่าน อาจจะทำให้เข้าใจเป็นอีกเรื่องได้ แน่นอนว่า มี Library ที่ใช้วิธีนี้เราน่าจะรู้จักกันเป็นอย่างดีเลย เจอกันทุกวัน คือ พวก Pandas และ Seaborn

2. เลือกของโชว์

วิธีแรก สิ่งที่สำคัญอย่างนึงเลยคือ เราไม่สามารถเลือก Function ที่เราอยากให้ User ใช้ได้ ทำยังไงดีละ ถึงเราจะเก็บบาง Function เอาไว้เป็น Private Function ไม่ให้ข้างนอกเรียกเข้ามา ง่ายมาก ๆ เราก็แค่ Import Function ที่เราต้องการให้ใช้แค่นั้นสิไม่ยากเลย

from data_container import load_data, write_to_file
from data_operation import increase_number, decrease_number
__init__.py

จากตัวอย่างด้านบน เราก็จะเห็นได้ว่า แทนที่เราจะดึงลงมาทุก Function ในทุก ๆ Module เลย เราก็ดึงลงมาใช้แค่บาง Function เท่านั้น เช่น increase_number และ decrease_number ที่อยู่ใน Module data_operation เป็นต้น ที่จริง ๆ แล้ว Function ที่เราปล่อยออกมาให้เข้าถึงได้ มันก็อาจจะต้องพึ่งพา Function ใส่ในอีกที แต่มันก็เป็นเรื่องที่ผู้ใช้ไม่ต้องแคร์นั่นเอง เลยปิดไว้ดีกว่า

import thip_package

my_data = thip_package.read_data('data_file.txt')
my_data = thip_package.increase_number(my_data)

ด้วยการที่เราเขียน Init File เหมือนเดิมเลย แค่เราเลือก Function มาเท่านั้น ทำให้ การใช้งานจริง ๆ ก็ยังคงเหมือนเดิมคือ เป็นลักษณะของ Top-Level เลย หรือก็คือเป็นชั้นเดียวเลย ไม่มี Submodule อะไรทั้งสิ้น

วิธีนี้เอาจริง ๆ ก็แอบมีปัญหาอยู่หน่อยเหมือนกันคือ เราจะเห็นว่า เรากำหนด Function ที่เราต้องการ Import เข้ามาเลย ทำให้เมื่อเราจะเพิ่ม หรือลบ Function ใน Module หรือแม้กระทั่งการเพิ่ม หรือ ลบ Module เอง เราก็ต้องมานั่งแก้และเช็คว่า ตัวไหนมีแล้วหรือยังนะ ทุกครั้ง ทำให้ปวดหัวมาก ๆ เลยทีเดียว แต่เราก็พอจะแก้ปัญหานี้ได้ เราคิดแบบเร็ว ๆ เลยนะ เราก็แค่เขียนเป็น Build Script ไว้ก็ได้ เมื่อเราทำเสร็จ เราก็ให้มันเข้าไปอ่านว่า ตัวไหนเป็น Top-Level Function แล้ว Generate Init File เอาเลยก็ได้ ไปผูกกับ Git Hook ก็ได้ ง่ายดี

3. เรียงของ

ปัญหาจากการเรียงทั้ง 2 แบบคือ เมื่อเรามี Function เยอะ ๆ นึกถึง Tensorflow และ Keras อันนั้นคือโคตรเยอะของจริง มีหลายส่วนมาก ๆ การแบ่งเป็น Submodule ลงไปเรื่อย ๆ ทำให้ User สามารถใช้งาน และ เข้าใจได้ง่ายขึ้น ยกตัวอย่างง่าย ๆ ใน Keras เอง ถ้าเราอยากได้ Mean Squared Error Loss Function เราก็สามารถเรียกได้จาก tensorflow.keras.losses.MeanSquaredError ได้เลย จะเห็นได้เลยว่า การที่เราทำเป็น Module แบบนี้ เวลาเราอ่าน เราก็จะรู้เลยว่า อ่อ มันคือ Loss Function จากตัวชื่อ Module และเอา Mean Squared Error หรือแม้กระทั่ง Dense Layer เอง ก็จะใช้เป็น tensorflow.keras.layers.Dense ทำให้อ่านได้ง่ายเหมือนกัน

import thip_package.data_container
import thip_package.data_operation
__init__.py

การเขียน Init File ก็จะเปลี่ยนไปด้วยเช่นกัน จากเดิมที่เราดึงตัว Function ลงมาเลย เราก็ดึงลงมาเป็น Sub-Module แทน เวลาใช้งานก็จะเป็นเหมือนด้านล่าง

import thip_package

my_data = thip_package.data_container.read_data('data_file.txt')
my_data = thip_package.data_operation.increase_number(my_data)

การใช้งานจริง เราก็อาจจะ Import มาทั้ง Module ใหญ่เลยก็ได้ แล้วเราค่อยมาเรียก Submodule ทีหลังได้ หรือ เราอาจจะเรียกมันขึ้นมาทีละ Module แล้วใส่ Alias ให้มันก็ทำได้เหมือนกัน เหมือนกับที่เราย่อ tf จาก Tensorflow นั่นแหละ

ด้วยวิธีนี้ ทำให้เรื่องการ Update Init File จะทำก็ต่อเมื่อมันมีการเปลี่ยนแปลงระดับการเพิ่มหรือลด Sub-Module เลย ซึ่งเอาจริง ๆ คือใน Software ที่เขาเสถียรแล้ว การเพิ่มลดพวกนี้จะเกิดขึ้นน้อยมาก ๆ ทำให้เราก็ไม่ค่อยต้องกังวลกับเรื่องพวกนี้เท่าไหร่ นอกจากนั้น มันยังทำให้เราสามารถควบคุมสิ่งที่เราจะเปิดออกไปใช้ User เข้าถึงได้ด้วย ผ่านการทำ Sub ของ Sub-Module อีกที ทำให้เราสามารถเขียน Script ได้เป็นระเบียบในขณะเดียวกันก็ยังอ่านได้ง่ายขึ้น

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

สรุป

การเรียง Module บน Python 3 วิธีที่เราเอามาเล่า น่าจะเป็นเคสที่เราเจอกันเยอะมาก ๆ ทั้งใน Module ที่มีชื่อเสียง และ Module ที่เล็ก ๆ ก็ตามแต่  เราจะต้องเลือกใช้งานให้เหมาะสมกับงานของเรา เช่น ถ้างานเราเล็ก ไม่ได้ซับซ้อน เราก็อาจจะเลือกวิธีแรกเพราะเราไม่ต้องทำอะไรเยอะ ไม่ต้อง Maintain อะไรเยอะ แต่มันก็แลกมากับข้อเสียหลาย ๆ เรื่องอีก หรือวิธีที่ 3 เลยที่ดูเหมือนจะ Organised สุด แต่ก็หลายเป็นว่า บางทีมันก็ยากอีก หรือบางคนก็อาจจะเอามาผสม ๆ กันเลยก็มี แต่ถ้าทำแบบนั้นเราแนะนำว่า ให้เขียน Document ดี ๆ เลย ไม่งั้น โคตร งง ไปเลย ใช้ไปด่าคนเขียนไป อ้าว ตรู เองนิหว่า !