Điều xuyên suốt trong cuốn sách được tác giả thường xuyên đề cập là “carrying cost”. Đây là yếu tố hàng đầu ảnh hưởng đến tính bền vừng (sustainability) của phần mềm. “Carrying cost” ở đây là chi phí chúng ta luôn luôn phải trả khi xây dựng, sửa lỗi và bảo trì phần mềm. Hay như cách nói của lập trình viên huyền thoại Jeff Antwood “dòng code tốt nhất là không có dòng code nào cả”. Trong cuốn sách này, tác giả David Bryant Copeland lại lập luận rằng Rails mang trong mình giá trị đặc biệt “làm giảm “carrying cost” của các patterns phổ biến khi xây dựng web app”. Một số bài học mà t đã thu nhặt được từ cuốn sách này là
Pros
Cons
ENV
trong Ruby thì giống như là Hash nhưng thực ra nó là một dạng object đặc biệt triển khai bằng C. Do vậy mà giá trị boolean như “true” hoặc “false” thực ra là string “true” hoặc “false” nên đoạn code
if ENV["PAYMENTS_DISBLED"]
give_free_order
end
```ruby
sẽ luôn luôn thực hiện method `give_free_order` ngay cả khi set "PAYMENT_DISABLED" là "false". Đoạn code đúng sẽ là
if ENV["PAYMENTS_DISBLED"] == "true"
give_free_order
end
Với nhiều team trong giai đoạn nước rút triển khai features và chưa phải golive, một thói quen launch trực tiếp database trên production về local, nhất là khi QAs tìm được bug trên môi trường production nên dev muốn làm vậy để dễ dàng tái hiện bug. Điều này đã từng xảy ra ở team Ampo cho đến khi được engineering manager nhắc nhở. Đây là một tiền lệ xấu do 2 lý do
db/seeds.rb
. Nhờ đó cũng giúp dev mới có thể nhanh chóng onboard trong việc setup local. Đây cũng là một best practice một dự án open source LagoVậy thì nên viết ở đâu? Câu trả lời của David là đâu đó trong app
, có thể là app/services
hoặc app/businesslogic
Lý do bởi business logic là lõi cơ bản khiến phần mềm này khác phần mềm kia, tuy nhiên đây cũng là nơi tụ họp của sự phức tạp hoặc cần thiết hoặc là vô tình. Sự phức tạp cần thiết đến từ nhu cầu có thật của việc kinh doanh mà hệ thống cần đáp ứng. Sự phức tạp vô tình có thể là một dạng “technical debt” do nó không phải là thiết yếu cho việc kinh doanh nhưng lại một việc mà devs chúng ta đã làm trong quá trình xây hệ thống.
Một lý do khác là business logic sẽ gặp phải “churn” - sự thay đổi nhiều. Trong thực tế sự thay đổi diễn ra thường xuyên và có một kết luận từ nghiên cứu được trích dẫn trong sách có thể nói nôm na rằng “Khi code churn tăng, tỷ lệ bugs trên số dòng code cũng nhiều lên” và điều này làm tăng carrying cost.
Khái niệm “fan-in” là quan trọng ở đây. Fan-in là mức độ một method được các method khác hoặc class khác gọi đến. Mặc dù có một số yếu tố khác quyết định xem 1 module có tâm ảnh hưởng đến độ nào như là module đó đem lại doanh thu thì một nhân tố khác quyết định sự ổn định của hệ thống là mức độ fan-in của 1 module.
Như hình dưới đây thì Widget
model có fan-in cao do nhiều controllers và models khác đang gọi đến model này. Một điều có thể kết luận được là bug ở Widget
sẽ có mức độ tổn hại cao hơn bug ở WidgetFaxRating
do chỉ có 1 controller duy nhất (OrdersController
) sử dụng model này. Các method search
, find
, rate
, purchase
và purchase_by_fax
là các method cụ thể nắm giữ các phức tạp đó
Tuy nhiên khi ta chuyển các methods thuộc Widget
model vào services, chúng ta sẽ được 1 hình sau
nếu nhìn qua ta có thể kết luận rằng cấu trúc này còn phức tạp hơn nhiều cấu trúc cũ do Widget
vẫn bị một loạt các module khác gọi tới và thậm chí bây giờ chúng ta có thể một lớp hàng ở giữa các services. Quan sát này là đúng tuy nhiên các methods nắm giữ sự phức tạp (search
, rate
, purchase
và purchase_by_fax
) đã được phân tách và chuyển qua từng service và chỉ có fan-in bằng 1. Nhờ vậy mà model Widget
trở nên nhỏ gọn, ít code hơn nên sẽ dễ để trở nên stable hơn
Do đó mà ta không nên đưa business logic vào models
Chúng ta nên ưu tiên url có cấu trúc với query /widget_ratings?widget_id=1234
hơn là tạo ra sub-resource cho URL như /widget/:id/ratings
do với cách thứ 2, ta có thể hơi vội vã coi ratings
là resource con của widget
nhất là khi chưa có sự chắc chắn trong requirement. Nếu nhu cầu làm đẹp của URL là có, ta có thể sử dụng kỹ thuật custom routes
# config/routes.rb
# * Explain anything else non-standard
# Used in podcast ads for the 'amazing' campaign
get "/amazing", to: "widgets#index”
end
Giả sử chúng ta cần tạo feature rating
cho widget
resource. Vì tính năng này đủ nhỏ để chỉ cần 1 action update nên ta có thể tạo PATCH request đến route /widgets/:id
và đây là quyết định hợp lý. Nhưng khi có nhiều cách khác nhau để update widget
, khi đó code như sau
def update
if params[:widget][:rating].present?
# update the rating
else
# do some other sort of update
end
end
để kiểm tra xem loại update nào. Điều này dẫn đến việc tạo ra action cụ thể hơn với route như
resources :widgets, only: [ :show ] do
post "update_rating"
end
Cách này mặc dù giải quyết được vấn đề nhưng lại lệch khỏi chuẩn HTTP (với các action tiêu chuẩn create
, read
, update
và delete
). Điều này tạo ra một tâm lý ngược thay vì tập trung vào resource thì chúng ta lại quan tâm nhiều đến action. Tác giả đề xuất tập trung vào resource. Thay vì tạo 1 custom action update_rating
, ta nên tạo 1 resource mới widget_rating
# config/routes.rb
Rails.application.routes.draw do
resources :widgets, only: [ :show, :index ]
resources :widget_ratings, only: [ :update ]
Lời khuyên này có lẽ là thật sự “cliche” nhưng với rất nhiều dev việc phải đọc requirement kỹ càng và tạo cuộc họp với BA là điều khiến họ nản và lười. Tuy nhiên lời khuyên quan trọng ở đây cho mọi dev là
“Mistakes in data modeling are difficult to undo later and can create large carrying costs in navigating the problems created by insufficient modeling.”
Cái giá phải trả cho sai lầm khi modeling data sẽ lớn nhất là khi data chưa được model đủ. Ngoài việc biết rõ requirements ổn định còn cho phép dev thực hiện một số thủ thuật như dùng constraint
của SQL database để validate dữ liệu cho các requirements ổn định và giành việc validate các phần dữ liệu cho các requirement chưa ổn định cho code
Lý do bởi khi 1 service trả về dữ liệu, việc chỉ trả về boolean sẽ là thiếu sót nhất là trong trường hợp có lỗi. Với 1 result object thì các lỗi phức tạp từ Active Records/Models có thể được hiện rõ và tiện lợi hơn trong quá trình debug. Lưu ý rằng ngôn ngữ của rich result object cũng quan trọng, ví dụ như sau
class Result
attr_reader :widget
def initialize(created:, widget: nil)
@created = created
@widget = widget
end
def created?
@created
end
end
end
Tác giả sử dụng created?
chứ không phải là succeeded?
lý do bởi sự cụ thể trong việc khởi tạo hơn là sự chung chung xem một service có thành công hay ko. Khi đọc code của caller, ta cũng dễ nhận biết là service này làm gì
result = WidgetsCreator.new.create_widget(widget_params)
if result.created?
redirect_to widget_path(result.widget)
else
@widget = result.widget
render "new"
end
Một lợi ích khác là khi test thì rich result sẽ rõ ràng hơn. So sánh test code sau:
receive(:create_widget).and_return(
WidgetsCreator::Result.new(created: false)
)
với
receive(:create_widget).and_return(false)
ta sẽ dễ nhận biết trường hợp 1 fail hơn trường hợp 2. Ngoài ra việc không return Active Record vì mục đích ở đây là kiểm soát result, ta luôn có thể dễ dàng thêm dữ liệu cho result object nhưng việc này sẽ thay đổi ngữ nghĩa của Active Record object nếu làm tương tự.
Quan điểm này có thể gây tranh cãi tuy nhiên trong thời gian làm tại Ampo, tôi cũng nhận 1 điều rằng bên phía vận hành cần yêu cầu hỗ trợ thường xuyên cho các lỗi data. Việc có 1 rake task trong codebase sẽ giúp tránh việc phải ghi lại hoặc nhớ các script cho các lỗi data này. Hơn thế nữa logic vá các lỗi này trong service có thể giúp ta xây feature sử lỗi data này trên phía FE admin, cho phép operation staff. Rake task lý tưởng chỉ nên là 1 dòng code và gọi vào 1 service để xử lý logic. Lý do là để:
services
.services
Biến môi trường được sử dụng khác nhau ở nhiều apps trong hệ thống và tuỳ thuộc và dev của apps đó quyết định tên biến là gì. Tuy nhiên câu truyện tại Stitch Fix có thể khiến chúng ta muốn thống nhất các tên biết này.
TL,DR: Tại Stitch Fix, khi 1 third party gặp phải lỗ thủng bảo mật, công ty đã quyết định chủ động rotate các API keys internal của hệ thống. Tuy nhiên lúc này, lãnh đạo Stitch Fix nhận ra rằng các biến môi trường sử dụng cùng 1 key lại khác nhau giữa các apps trong hệ thống. Điều này khiến cho 1 task lẽ ra chỉ mất vài giờ lại khiến cho 6 devs phải làm trong 1 tuần
Every Environment Variable Name is Precious At Stitch Fix, there was a point where the team was around 50 developers and we had around 30 Rails apps in production as part of a microservices architecture. We had a gem that was used for consuming microservices, but the gem failed to bake in a convention about how to name the environment variable that held the API key. The result was that some apps would use SHIPPING_SERVICE_PASSWORD, some SHIPPING_API_KEY, some SHIPPING_SERVICE_KEY, and others SHIP_SVC_APIKEY. It was a mess. But, microservices did allow this mess to not affect the team as a whole. Until we needed to rotate all of these keys. A third party we used had a major security breach and there was a possibility that our keys could’ve been leaked. Rather than wait around to find out, we decided to rotate every single internal API key. If the environment variables for these keys were all the same, it would’ve taken a single engineer a few hours to write a script to do the rotation. Instead, it took six engineers an entire week to first make the variables consistent and then do the rotation. According to Glassdoor, an entry-level software engineer makes $75,000 a year, which meant this inconsistency cost us at least $9,000. The six engineers that did this were not entry-level, so you can imagine the true cost. Inconsistency is not a good thing. The consistency we paid for that week did, at least, have a wonderful return when we had to tighten our security posture before going public. The platform team was able to leverage our new-found consistent variable names to script a daily key rotation of all keys in less time and fewer engineers than it took to make the variable names consistent.
Có 2 tình huống căn bản khi sử dụng log
## => 2020-07-09 11:34:12 [WidgetCreator] <#Widget id=1234> updated
thì việc kỷ luật team trong quá trình review code, yêu cầu nội dung log trong code như log "#{self.class.name}: Widget #{widget.id} updated
có thể tạo sự mệt mỏi không cần thiết. Tác giả có viết 1 gem khác cho phần log message này
Các life-cycle methods cho phép các callback function được gọi trước hoặc sau khi lưu dữ liệu, trước hoặc sau khi xoá, xung quanh transactions… nên các life-cycle này có thể hữu ích khi điều tra hành vi của app mà không cần phải log trong nhiều phần code khác nhau.
Một ví dụ là khi ta cần loại bỏ 1 bảng cũ trong database nhưng app lại lớn nên ta không thể chắc bảng này được sử dụng ở đâu. Ta có thể sử dụng after_save
callback như sau
class LegacyWidget < ApplicationRecord
after_save :log_caller
private
def log_caller
Rails.logger.info "#{self.class} saved by #{caller[0]}"
end
end
Sau đó ta chỉ cần lọc log này trên production để tìm bất cứ code nào có Legacy Widget saved by
Cuốn sách muốn trình bày một luận điểm rằng giữa “Monolith, Micro-services và shared database” thì việc bắt đầu bằng microservices thường không phải là quyết định đúng, biểu đồ sau thể hiện chi phí của Monolith và Microservices theo thời gian
việc chuyển đổi từ Monolith sang Microservices là điều cần thiết do đến một thời điểm nhất định, chi phí vận hành Monolith trở nên cao hơn so với Micro-services, mức chi phí chuyển đổi được tác giả thể hiện ở biểu đồ sau
Để tránh việc phải trả mức phí này, chí ít là lúc đầu, theo như tác giả là việc sử dụng “shared-database”
Tuy nhiên thì suy cho cùng, share-database dường như chỉ trì hoãn vấn đề, nhưng cũng đủ làm bước đệm
Tại Pressingly, team đã sử dụng một kiến trúc khác nằm giữa Monolith và Micro-services cho phép sự chuyển đổi này ở mức chi phí thấp hơn. Kiến trúc này là “Modular Monolith” và hy vọng sẽ được chia sẻ vào các buổi nói chuyện sau