ในโพสต์วันนี้จะ looking into software design pattern ชื่อ Facade ตอน adopt ครั้งแรกรู้สึก awkward นิดหน่อย แต่ยิ่งใช้ใน Rails apps ยิ่ง appreciate usefulness สำคัญกว่านั้น allowed me to test code thoroughly, clean out controllers, reduce logic ใน views และทำให้คิดชัดเจนขึ้นเกี่ยวกับ overall structure ของ code
เป็น software development pattern facade framework agnostic แต่ examples ที่นี่สำหรับ Ruby on Rails แนะนำอ่านผ่านและลอง不管 framework ที่ใช้ มั่นใจว่า once familiar กับ pattern นี้ จะเริ่มเห็น opportunities ใช้ในหลายส่วนของ codebase
Without further ado, let's dive right in!
The Problem with the MVC Pattern
MVC (Model-View-Controller) pattern เป็น software development pattern ตั้งแต่ 1970s Battle-tested solution สำหรับ design software interfaces แยก programming concerns เป็นสามกลุ่มหลักที่ communicate ระหว่างกัน uniquely
Web frameworks ใหญ่หลายตัว emerge early 2000s ด้วย MVC pattern เป็น foundation Spring (Java), Django (Python) และ Ruby on Rails (Ruby) forged กับ trinity ของ interconnected elements นี้ที่ core เทียบกับ spaghetti-code จาก software ที่ไม่ใช้ MVC pattern เป็น huge achievement และ turning point ใน evolution ของ software development และ internet
In essence MVC pattern allows: user performs action on View View triggers request to Controller ซึ่ง potentially create/read/update/delete Model Model transaction responds back to Controller ซึ่ง render change ที่ user เห็น reflected ใน View
Pros เยอะของ pattern นี้ รายการบ้าง:
- Improve code maintainability โดย separate concerns
- Allow greater testability (Models, Views, Controllers test isolated ได้)
- Encourage good coding practices โดย enforce Single Responsibility Principle ของ SOLID: "A class should have only one reason to change."
Phenomenal achievement for its time developers ตระหนักเร็วว่า MVC pattern limiting ด้วย Variants emerged เช่น HMVC, MVA, MVP, MVVM และอื่นๆ ซึ่ง sought to address limitations ของ MVC
หนึ่งในปัญหาที่ MVC introduce และ topic ของ article วันนี้คือ: ใคร responsible สำหรับ handle complex view logic? View ควร concerned แค่ presenting data Controller just relaying message จาก Model และ Model ไม่ควร concerned กับ view logic ใดๆ
ช่วย common conundrum นี้ ทุก Rails apps initialized ด้วย helpers directory helper directory มี modules with methods ช่วย complex View logic
Example helper ใน Rails app:
app/helpers/application_helper.rb
module ApplicationHelper
def display_ad_type(advertisement)
type = advertisement.ad_type
case type
when 'foo'
content_tag(:span, class: "foo ad-#{type}") { type }
when 'bar'
content_tag(:p, 'bar advertisement')
else
content_tag(:span, class: "badge ads-badge badge-pill ad-#{type}") { type }
end
end
end
Example นี้ simple แต่ demonstrate ว่าอยาก extract decision making นี้จาก template เองเพื่อ reduce complexity
Helpers ไม่錯 แต่ยังมี pattern อีกอันสำหรับ handle complicated View logic ที่ accepted มาปีหลายๆ คือ Facade pattern
Introduction to the Facade Pattern
ใน Rails application facades ปกติวางใน app/facades directory
คล้าย helpers แต facades ไม่ใช่ group of methods ใน module Facade คือ PORO (Plain Old Ruby Object) ที่ instantiated ภายใน controller แต่ handle elaborate View business logic ทำให้ได้ benefits:
- แทน single module สำหรับ
UsersHelperหรือArticlesHelperหรือBooksHelperแต่ละ controller action มี Facade ของตัวเอง:Users::IndexFacade,Articles::ShowFacade,Books::EditFacade - มากกว่า modules facades encourage good coding practices โดย allow nest facades เพื่อ enforce Single Responsibility Principle แม้ไม่อยาก facades nested hundreds of levels deep แต่ one or two layers nesting สำหรับ improved maintainability และ test coverage เป็น good thing
Contrived example:
module Books
class IndexFacade
attr_reader :books, :params, :user
def initialize(user:, params:)
@params = params
@user = user
@books = user.books
end
def filtered_books
@filtered_books ||= begin
scope = if query.present?
books.where('name ILIKE ?', "%#{query}%")
elsif isbn.present?
books.where(isbn: isbn)
else
books
end
scope.order(created_at: :desc).page(params[:page])
end
end
def recommended
# We have a nested facade here.
# The `Recommended Books` part of the view has a
# single responsibility so best to extract it
# to improve its encapsulation and testability.
@recommended ||= Books::RecommendedFacade.new(
books: books,
user: user
)
end
private
def query
@query ||= params[:query]
end
def isbn
@isbn ||= params[:isbn]
end
end
end
When Not to Use the Facade Pattern
Reflect บนสิ่งที่ facades ไม่ใช่
Facades ไม่ควรวางใน classes ที่อยู่ เช่น
libdirectory สำหรับ code ที่ต้อง displayed ใน View Facade lifecycle ควร generated ใน Controller action และใช้ใน associated ViewFacades ไม่ meant สำหรับ business logic perform CRUD actions (มี patterns อื่น เช่น Services หรือ Interactors — แต่วิชานั้นเป็น subject for another day) กล่าวคือ facades ไม่ควร concerned กับ creating, updating หรือ deleting Aim คือ extract intricate presentation logic จาก View หรือ Controller และ offer single interface เข้าถึงข้อมูลทั้งหมด
Last but not least Facades ไม่ใช่ silver bullet ไม่ให้ bypass MVC pattern แต่ play along กับมัน ถ้า change เกิดใน Model จะไม่ reflected ใน View ทันที ตามปกติ MVC controller action ต้อง re-render ให้ Facade แสดง changes บน View
Controller Benefits
หนึ่งใน main benefits ของ Facades คือ dramatically reduce controller logic
Controller code ลดจากแบบนี้:
class BooksController < ApplicationController
def index
@books = if params[:query].present?
current_user.books.where('name ILIKE ?', "%#{params[:query]}%")
elsif params[:isbn].present?
current_user.books.where(isbn: params[:isbn])
else
current_user.books
end
@books.order(created_at: :desc).page(params[:page])
@recommended = @books.where(some_complex_query: true)
end
end
เหลือแค่นี้:
class BooksController < ApplicationController
def index
@index_facade = Books::IndexFacade.new(user: current_user, params: params)
end
end
View Benefits
สำหรับ Views มี two main benefits ใช้ Facades:
- Conditional checks, inline queries และ logic อื่นๆ extract จาก template เนียะ code readable มากขึ้น เช่นใช้ใน form:
<%= f.label :location %>
<%= f.select :location, options_for_select(User::LOCATION_TYPES.map { |type| [type.underscore.humanize, type] }.sort.prepend(['All', 'all'])), multiple: (current_user.active_ips.size > 1 && current_user.settings.use_multiple_locations?) %>
กลายเป็น:
<%= f.label :location %>
<%= f.select :location, options_for_select(@form_facade.user_locations), multiple: @form_facade.multiple_locations? %>
- Variables ที่เรียกหลายครั้ง cache ได้ ให้ significant performance improvements และช่วย remove pesky N+1 queries:
// ที่ view บางส่วน queryPerformed
<% current_user.books.where(isbn: params[:isbn]).each do |book| %>
// Do things
<% end %>
// ที่อื่นใน view query เดียวกัน performed อีกครั้ง
<% current_user.books.where(isbn: params[:isbn]).each do |book| %>
// Do things
<% end %>
กลายเป็น:
// ที่ view บางส่วน queryPerformed
<% @index_facade.filtered_books.each do |book| %>
// Do things
<% end %>
// ที่อื่นใน view
// Second query ไม่ performed เนื่องจาก instance variable caching
<% @index_facade.filtered_books.each do |book| %>
// Do things
<% end %>
Testing Benefits
Major benefit ของ Facades คือ allow test singular bits of business logic โดยไม่ต้องเขียน entire controller test หรือ worse integration test ที่ go through flow และ reach page เพื่อ ensure data presentation as expected
Test single POROs ช่วย maintain fast test suite
Simple example test ใน Minitest สำหรับ demonstration:
require 'test_helper'
module Books
class IndexFacadeTest < ActiveSupport::TestCase
attr_reader :user, :params
setup do
@user = User.create(first_name: 'Bob', last_name: 'Dylan')
@params = {}
end
test "#filtered_books returns all user's books when params are empty" do
index_facade = Books::IndexFacade.new(user: user, params: params)
expectation = user.books.order(created_at: :desc).page(params[:page])
# Without writing an entire controller test or
# integration test, we can check whether using the facade with
# empty parameters will return the correct results
# to the user.
assert_equal expectation, index_facade.filtered_books
end
test "#filtered_books returns books matching a query" do
@params = { query: 'Lord of the Rings' }
index_facade = Books::IndexFacade.new(user: user, params: params)
expectation = user
.books
.where('name ILIKE ?', "%#{params[:query]}%")
.order(created_at: :desc)
.page(params[:page])
assert_equal expectation, index_facade.filtered_books
end
end
end
Unit testing facades considerably improves test suite performance และทุก large company จะเจอ slow test suites เวลานี้ ถ้า problems เหล่านี้ไม่ addressed ด้วย seriousness ระดับหนึ่ง
One Facade, Two Facades, Three Facades, More?
อาจเจอ scenario view render partial output data ในกรณีนี้มี option ใช้ parent facade หรือ nested facade ขึ้นอยู่กับ logic involved มากแค่ไหน อยาก test แยกหรือไม่ และ extract functionality makes sense
No golden rule สำหรับ how many facades to use หรือ how many facades to nest within each other ขึ้นอยู่กับ developer discretion ผม generally prefer single facade สำหรับ controller action และ limit nesting ไป single level ทำ code easier to follow
General questions ถามตัวเองระหว่าง development:
- Facade encapsulate logic ที่พยายาม present บน view หรือไม่?
- Method ภายใน facade make sense ใน context นี้ไหม?
- Code easier to follow ตอนนี้ หรือ harder?
When in doubt always strive to make code as easy to follow as possible
สรุป
In conclusion facades เป็น fantastic pattern เก็บ controllers และ views lean พร้อม improve code maintainability, performance และ testability
แตเหมือน programming paradigm ใดๆ ไม่มี silver bullet แม้ patterns หลายตัว emerged ใน recent years (HMVC, MVVM, ฯลฯ) ไม่ใช่ be-all-end-all solutions สำหรับ complications ของ software development
คล้าย second law of thermodynamics ระบุว่า state of entropy ใน closed system จะ always increase เหมือนกันใน software project ใดๆ complexity เพิ่มและ evolve ตลอดเวลา In the long run goal คือเขียน code ที่ easy to read, test, maintain และ follow มากที่สุด facades offer exactly this
P.S. หากอยากอ่าน Ruby Magic posts ทันทีที่เผยแพร่ สมัครสมาชิก Ruby Magic newsletter และไม่พลาดโพสต์เด็ดๆ!