back arrow

Những bài học từ cuốn Sustainable Web Development with Ruby on Rails

22 - 08 - 2024

Đ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à

1. Pros và Cons của Rails application architecture

Rails app architecture

Pros

Cons

2. Cẩn thận với ENV

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

3. Không nên copy database trên production về local khi dev

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

4. Không viết business logic trong model

Vậ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 ở WidgetFaxRatingdo chỉ có 1 controller duy nhất (OrdersController) sử dụng model này. Các method search, find, rate, purchasepurchase_by_fax là các method cụ thể nắm giữ các phức tạp đó

Fat model logic

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 Logic at services

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, purchasepurchase_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

5. Cẩn thận với sub-resource

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

6. Đừng tạo nhiều actions, hãy tạo resources

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, updatedelete). Đ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 ]

7. Giành thời gian để tìm ra requirements nào ổn định

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

8. Trả về rich result cho service chứ không phải boolean hay Active Record object

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ự.

9. Logic của rake task cũng nên nằm trong services

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à để:

10. Thống nhất tên biến môi trường

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.

11. Bốn kỹ thuật nên có khi log

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

12. Life-cycle methods hữu ích trong việc tìm hiểu hành vi của app mà không cần thêm nhiều log

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

13. Đừng vội vã sử dụng microservices

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 Monolith vs microservices

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 Microservice trasition from monolith

Để 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” Shared dâtbase

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