Khởi đầu dự án Python như thế nào để thuận tiện phát triển lên

Thỉnh thoảng mình có mối duyên ghé mắt qua các dự án Python, thấy cách sắp đặt vẫn còn chuệch choạc, không có lợi lắm cho việc phát triển tiếp diễn. Nên sau đây mình chia sẻ một số cách thức, công cụ, thư viện mà bạn nên chuẩn bị từ đầu, để công việc sau đó trở nên thoải mái hơn. Cách sắp đặt này có thể coi là chuẩn trong những năm 2020 này (nhưng có thể trở thành lạc hậu sau 5 năm nữa).

1. Quản lý các gói phụ thuộc

Gói phụ thuộc (dependency) là các thư viện / công cụ bên ngoài mà dự án của bạn cần. Các gói này phải được cài trước khi phần mềm của bạn có thể chạy. Ví dụ bạn làm về khoa học dữ liệu thì sẽ cần NumPy, làm web thì sẽ cần Django v.v... Việc một dự án phụ thuộc vào hàng chục gói thư viện khác là chuyện bình thường. Thông thường các gói này sẽ được liệt kê trong file requirements.txt để khi sao chép dự án sang máy khác thì biết cần cài cái gì. Tuy nhiên, file requirements.txt chỉ là hình thức tối thiểu để quản lý gói phụ thuộc. Nó không đủ để hỗ trợ tình huống phức tạp hơn. Ví dụ dự án của bạn sử dụng thư viện A phiên bản v1 và B phiên bản v2. Sau vài tháng nhu cầu nảy sinh, bạn cần thêm tính năng mới, và để làm tính năng mới, bạn cần đến thư viện C. Tuy nhiên thư viện C này cũng lại phụ thuộc thư viện A, và thư viện C đang có nhiều phiên bản, v1 đến v5, mỗi phiên bản của C sẽ thương thích với một phiên bản A khác nhau. Nếu bạn nhắm mắt chọn phiên bản mới nhất của C thì nó sẽ yêu cầu A v3. Bạn không thể mù quáng nâng cấp A lên v3 vì có thể phần mềm của bạn không tương thích và đứt gãy. Nhưng trong 5 phiên bản của C mà thử từng cái một thì rất cực. Đó là lúc bạn cần một thứ nâng cao hơn file requirements.txt.

Một công cụ hiện đại mà mình hay dùng, và khuyên dùng cho tình huống này là Poetry. Khi bạn cần thêm C vào danh sách phụ thuộc, chỉ cần chạy:

$ poetry add C

thì Poetry sẽ tính toán để chọn phiên bản C phù hợp nhất.

Trước khi Poetry ra đời thì có một công cụ khác cũng khá nổi tiếng là Pipenv. Tuy nhiên cá nhân mình không thích Pipenv vì nó tạo thư viện môi trường ảo virtual environment ngay bên trong thư mục dự án. Việc đặt môi trường ảo ngay bên trong dự án này có một phiền phức là khi bạn cần chạy công cụ gì cần quét tất cả các file mã nguồn, nhiều khi nó vô tình quét trúng các file trong thư mục môi trường ảo, nhẹ nhất là làm nhiễu kết quả, nặng nhất là chạy luôn các code trong đó và bắn ra lỗi tùm lum không biết đường nào mà lần.

Việc đặt môi trường ảo ở đâu cũng là một "kinh nghiệm" đáng lưu tâm khi khởi tạo dự án.

Để sử dụng Poetry ngay từ đầu thì bạn có thể dùng các lệnh poetry new, poetry init để nó tạo ra các file cần thiết và cấu trúc thư mục mẫu.

Khi dùng Poetry, có một cái lợi khác là nó sẽ áp dụng file pyproject.toml cho dự án của bạn. Đây là một chuẩn file mới của Python, sẽ là nơi tập trung để lưu cấu hình của các công cụ bổ trợ (kiểm tra code, làm sạch code) trong quá trình phát triển dự án.

Ghi chú:

Trong năm 2023, một công cụ khác nổi lên, có vẻ sẽ thay thế Poetry: PDM. Ưu điểm của PDM là dùng định dạng chuẩn của file pyproject.toml được quy định trong PEP-621.

2. Ghi log

Khi ứng dụng của bạn đã đem ra triển khai, chạy thật, rồi vào một ngày đẹp trời bạn nhận được phản ánh là nó có lỗi, thậm chí có thể nó đang bị lỗi ngay trước mặt bạn. Nhưng bạn không biết chuyện gì xảy ra bên trong phần mềm, chẳng hạn nó đang đọc dữ liệu từ một nguồn nào đó nhưng gặp phải nội dung không mong muốn, không phân tích được và đổ bể. Hoặc là nó đang thực hiện phép toán chia giữa hai biến, và chẳng may biến mẫu số đang là 0, thế là sập. Để chuẩn bị trước cho những tình huống này, bạn cần làm cho phần mềm ghi ra log, "kể lại" những diễn biến, để sau này khi có sự cố, bạn đọc lại log và có manh mối để biết phải sửa chỗ nào.

Ưu điểm của Python là nó có sẵn thư viện logging trong bộ thư viện chuẩn để các phần mềm tận dụng cho việc ghi log. Nhờ nó là "chuẩn", các thư viện đều dùng nó nên bạn có thể tùy ý bật tắt log của các thư viện bên dưới mà ứng dụng của bạn đang sử dụng.

Ví dụ, ứng dụng của bạn đang dùng thư viện requests bên dưới, có tình huống mà log từ ứng dụng của bạn ghi ra vẫn không tiết lộ được gì về chuyện gì sai xảy ra bên trong. Bạn cần kiểm tra dữ liệu HTTP mà requests thu nhận được có đúng đắn không. Khi đó bạn chỉ cần tăng cấp độ log (log level) của requests mà không cần đụng vào code của nó, để thấy được chi tiết hơn.

Việc bạn cần làm, chỉ đơn giản thế này:

logger = logging.getLogger('requests')
logger.setLevel(logging.DEBUG)

Thậm chí, bạn có thể điều khiển một module con nào đó của thư viện thôi, để giấu bớt log của các module khác:

logging.getLogger('requests').setLevel(logging.WARNING)
logging.getLogger('requests.api').setLevel(logging.DEBUG)

Khi tham gia làm việc trên một ngôn ngữ khác không có thư viện logging chuẩn như thế này, ví dụ NodeJS, nơi mà mỗi thư viện, mỗi ứng dụng tự chế một cách ghi log riêng, bạn sẽ thấy cách làm của Python hữu ích thế nào.

Tuy nhiên, việc kèm sẵn một thư viện logging trong bộ thư viện chuẩn cũng có bất cập riêng. Đó là Python sẽ không dễ dàng nâng cấp nó, vì sợ sẽ làm đổ bể hàng loạt thư viện / ứng dụng khác. Ví dụ, để chèn nội dung biến vào log, logging trước giờ vẫn dùng cú pháp "%" như sau:

user = get_user_from_facebook()
logger.debug('Got user with name %s and age %d', user.name, user.age)

Vấn đề là, format chuỗi bằng "%s" là phương pháp cũ kĩ, thời v2.5 về trước. Từ v3 (và được backport vào v2.6), Python có cách format chuỗi đơn giản hơn là "{}". Nếu áp dụng được "{}" vào logging thì code trên sẽ trở thành:

user = get_user_from_facebook()
logger.debug('Got user with name {} and age {}', user.name, user.age)

Vừa nhìn thoáng hơn mà lại không phải lăn tăn suy nghĩ chọn "%s" hay "%d", "%f". Tuy nhiên, thư viện logging không thể cải tiến chỗ này, vì sợ thay đổi cái là toàn bộ các thư viện có dùng logging trong hệ sinh thái sẽ sập hết.

Ghi chú: logging có cho phép cấu hình lại để sử dụng "{}", nhưng việc thực hiện khá rườm rà và trong một dự án phức tạp thì chưa biết độ tương thích với các thư viện khác ra sao.

Ngoài vấn đề dùng "%s" ra thì logging có một điểm trừ là phong cách code với chữ hoa, ví dụ getLogger, setLevel không đồng bộ với phong cách chuẩn PEP8 của Python. Nhìn hơi ngứa mắt nên thực tế, khi làm phần mềm ở tầng "ứng dụng" (application) thì mình dùng logbook thay cho logging. Với logbook thì ngay từ đầu đã có thể dùng "{}" và có phong cách chuẩn PEP8 rồi, ví dụ:

from logbook import Logger

logger = Logger(__name__)


# Some code
# ...

user = get_user_from_facebook()
logger.debug('Got user with name {} and age {}', user.name, user.age)

Tuy nhiên, logbook không phải là thuốc tiên, nếu dùng sai liều là nó sẽ thành thuốc độc, bạn sẽ vò đầu bứt tai cả buổi mà không hiểu tại sao không thấy log đâu. Có một vài điều bạn cần lưu ý khi dùng logbook:

  • logbook có cách chuẩn bị rất khác với logging, với bước gọi handler.push_application(). Với một dự án đồ sộ có nhiều điểm khởi động, ví dụ Django, có các lệnh khác nhau để chạy dev server, WSGI server, job queue, nếu đặt push_application() sai chỗ, bạn sẽ thấy log hiện ra khi chạy từ lệnh này, nhưng lại không hiện khi chạy lệnh kia.

  • logbook được điều khiển độc lập với logging, không dùng chung cấu hình. Nghĩa là nếu bạn điều chỉnh "độ lắm lời" của module nào đó qua logging, nó sẽ không tác động gì đến logbook và ngược lại.

  • Bạn cần quan tâm tới nơi thu gom log cuối cùng (ví dụ ghi vào file hay gửi lên một dịch vụ online nào đấy), và trả lời câu hỏi rằng log từ logbook có cần chảy về chung đầu mối với log sinh ra từ logging hay không. Nếu không, coi chừng khi xem nơi lưu trữ cuối cùng, bạn sẽ hoang mang khi thấy log của hệ này mà không thấy hệ kia.

  • Nếu bạn muốn quy về một mối chung, dùng logbook.compat.LoggingHandler, bạn cần lưu ý một hạn chế của handler này là khi redirect logbook, nó không quan tâm log đó của module con nào, dồn về hết root logger của logging. Tôi đã gửi một pull request để sửa lỗi này nhưng tác giả của logbook không mặn mà nên tôi tự tạo thư viện riêng, chameleon-log để cung cấp handler đã sửa.

Lưu ý: Tôi đã thấy nhiều lập trình viên dùng log rất sai lầm, ví dụ:

user = get_user_from_facebook()
logger.debug(f'Got user with name {user.name} and age {user.age}')
logger.debug('Got user with name {} and age {}'.format(user.name, user.age))
logger.debug('Got user with name ' + user.name + ' and age ' + str(user.age))

Tức là bạn ấy chèn thẳng biến vào chuỗi tham số đầu tiên chứ không phải truyền gián tiếp qua tham số thứ 2, 3. Lý do là chuỗi ở tham số đầu tiên chỉ đóng vai trò "giữ chỗ". Nếu đang bật cấp độ (log level) phù hợp thì chuỗi đó mới được format với các tham số phía sau để tạo ra message cuối cùng và xuất ra thành log. Nếu bạn chèn thẳng biến vào chuỗi, chuỗi đó sẽ được tính toán, các biến sẽ được xử lý trước khi log level được kiểm tra, làm lãng phí công sức của ứng dụng.

Thêm một "bí kíp" nữa, là việc chọn điểm thu gom log cuối cùng. Cách cổ điển là khi ra file, nhưng đó không phải là cách hay đâu. Nếu ứng dụng của bạn chạy trên Linux (server hay máy tính nhúng), nên cho nó ghi ra journald, bộ phận quy tập log của systemd. Ưu điểm của việc chuyển log cho journald là:

  • Journald gom log của mọi dịch vụ về một chỗ. Khi bạn đang quản lý hệ thống với nhiều phần mềm, sẽ thật nhức đầu nếu phần mềm này xem log ở chỗ này, phần mềm kia xem log ở chỗ kia. Quy về một mối thì bạn chỉ cần dùng lệnh journalctl -u ten-app là xem được log ứng dụng của bạn rồi.
  • Journald có nhiều phương thức để rà soát log một cách tiện lợi, ví dụ bạn muốn xem kiểu "bám đuổi", log ghi ra tới đâu, xem ngay tới đó thì dùng lệnh journalctl -fu ten-app (-f nghĩa là "follow").
  • Nếu bạn không cần bám đuổi mà muốn nó đứng một chỗ (để soi kĩ hơn), nhưng muốn "nhảy cóc" xuống dòng cuối cùng (dòng mới nhất) thì dùng lệnh journalctl -eu ten-app (-e nghĩa là "end").
  • Khi bạn đang xem log bằng journalctl, bạn có thể tìm chuỗi để nhảy tới nhảy lui, bằng lệnh "/" (tìm xuôi) hoặc "?" (tìm ngược). Thực ra là bạn có thể dùng bất kì lệnh / phím tắt nào của less ở đây.
  • Journald cho phép chọn lọc theo ngày tháng. Chẳng hạn bạn biết rằng 9h tối hôm qua có sự cố. Bạn muốn xem log xung quanh thời điểm đó thôi, bạn có thể dùng --since, --until để cắt bớt, ví dụ journalctl -u ten-app --since '2021-08-13 21:00'.
  • Journalctl xác định thời gian bằng thời điểm nó nhận được log, chứ không phải ngày tháng kèm trong log, nên nó "miễn nhiễm" với sự điều chỉnh múi giờ. Chẳng hạn 9h tối qua xảy ra sự cố nhưng thời điểm đó server đang bị cấu hình nhầm múi giờ UTC+8, đến 11 giờ bạn nhận ra múi giờ sai nên vào server chỉnh lại thành UTC+7. Sáng nay bạn cần xem lại log thì không cần tính toán lại giờ theo múi giờ của tối qua.

Dưới đây là hình ảnh log được xem bằng journalctl của một ứng dụng IoT của chúng tôi. Để ý là journalctl tô màu dòng log theo độ nghiêm trọng (log level) để giúp tập trung dễ hơn.

journalctl

Mặc dù bây giờ cũng có phương án là gửi lên các dịch vụ cloud (như StackDriver của Google, CloudWatch của AWS), nhưng tôi không thích dùng chúng, vì thao tác tìm kiếm / nhảy cóc trên journalctl nhanh ra kết quả hơn. Các dịch vụ kia là giao điện web nên mỗi lần tìm kiếm bạn phải chờ trang web tải, rồi bấm vào link này link nọ, rồi lại chờ. Các dịch vụ đó chỉ có giá trị cho việc cài đặt cảnh báo, thống kê . Vì vậy khi cấu hình logging, tôi sẽ dùng hai "handler", một ghi xuống journald, một để gửi log lên dịch vụ cloud.

Trên đây là một số kinh nghiệm khi tạo dựng từ đầu một dự án Python, nhưng chắc nó cũng đáng giá với các ngôn ngữ khác nữa (chỉ thay đổi phần mềm tương ứng). Kinh nghiệm còn nhiều nhưng hôm nay chỉ ghi ra tới đây thôi. Mốt nhớ ra kể tiếp.