Single Responsibility Principle trong SOLID
Bài đăng này đã không được cập nhật trong 6 năm
1. Single responsibility principle là gì?
Theo Wikipedia: "Một class chỉ nên mang 1 trách nhiệm duy nhất mà thôi", và theo Robert C.Martin: "Một class chỉ nên có duy nhất 1 lý do để phải thay đổi". Vậy kết hợp lại, ta có định nghĩa cho Single responsibility principle: "Một lớp chỉ nên mang 1 trách nhiệm duy nhất và chỉ nên có duy nhất 1 lý do để phải thay đổi".
Đôi khi, các lập trình viên phải đối mặt với khái niệm "responsibility". Thật dễ dàng khi chỉ thêm 1 dòng code vào phương thức, rồi sau đó lại thêm 1 dòng nữa, cứ thế và bạn có 1 class khổng lồ phải xử lý bao nhiêu thứ.
Một kỹ năng mà bất kỳ lập trình viên nào cũng nên có: biết khi nào class bắt đầu trở nên phình to và cần tối ưu lại thành các class nhỏ hơn.
Để dễ hình dung về nguyên lý này, hãy cùng xem ví dụ sau nhé:
2. Ví dụ
Ngày nay, việc sử dụng API khá là phổ biến. Trong ví dụ này, mình cần BlogService
gọi đến 1 API để lấy ra danh sách các bài viết của blog.
require 'net/http'
require 'json'
class BlogService
def initialize(environment = 'development')
@env = environment
end
def posts
url = 'https://jsonplaceholder.typicode.com/posts'
url = 'https://prod.myserver.com' if env == 'production'
puts "[BlogService] GET #{url}"
response = Net::HTTP.get_response(URI(url))
return [] if response.code != '200'
posts = JSON.parse(response.body)
posts.map do |params|
Post.new(params)
end
end
private
attr_reader :env
end
class Post
attr_reader :id, :user_id, :body, :title
def initialize(attributes = {})
@id = attributes['id']
@user_id = attributes['user_id']
@body = attributes['body']
@title = attributes['title']
end
end
blog_service = BlogService.new
puts blog_service.posts.inspect
Trước hết, hãy cùng tìm ra các trách nhiệm mà class BlogService
đang phải gánh nhé.
Cấu hình
Chính là các dòng sau:
url = 'https://jsonplaceholder.typicode.com/posts'
url = 'https://prod.myserver.com' if env == 'production'
Dựa vào môi trường thực thi, chúng ta có thể thay đổi địa chỉ gốc của API.
Logging
puts "[BlogService] GET #{url}"
HTTP Request
response = Net::HTTP.get_response(URI(url))
return [] if response.code != '200'
Chúng ta cũng nên xử lý khác đi với response code khác 200 thay vì chỉ trả về kết quả rỗng.
Xử lý kết quả trả về
Chúng ta sẽ nhận về kết quả dạng như:
[{
"userId": 10,
"id": 95,
"title": "id minus libero illum nam ad officiis",
"body": "earum voluptatem facere..."
},
{
"userId": 10,
"id": 96,
"title": "quaerat velit veniam amet cupiditate aut numquam ut sequi",
"body": "in non odio excepturi sint eum..."
}, ...]
Chúng ta cần phải đưa kết quả của request trên từ JSON thành mảng các hash và trả về mảng các đối tượng Post
:
posts = JSON.parse(response.body)
posts.map do |params|
Post.new(params)
end
Vậy là có ít nhất 4 trách nhiệm cho class trên. Để đảm bảo nguyên lý Single responsibility thì chúng ta hãy thử tách class trên thành nhiều class nhỏ như sau nhé:
class BlogServiceConfig
def initialize(env:)
@env = env
end
def base_url
return 'https://prod.myserver.com' if @env == 'production'
'https://jsonplaceholder.typicode.com'
end
end
Một class đơn giản với 1 nhiệm vụ duy nhất là trả về cấu hình cho blog service. Hiện tại thì nó mới chỉ có trả về base_url
thôi, nhưng nếu cần thiết thì sau này có thể mở rộng dễ dàng.
Bây giờ chúng ta có thể sử dụng class này trong BlogService
như sau:
class BlogService
# ...
def posts
url = "#{config.base_url}/posts"
puts "[BlogService] GET #{url}"
response = Net::HTTP.get_response(URI(url))
# ...
end
private
# ...
def config
@config ||= BlogServiceConfig.new(env: @env)
end
end
Tiếp tục với nhiệm vụ Logging nhé. Có thể chúng ta sẽ cần sử dụng đến lớp này để log cho các request khác nữa, cho nên, mình sẽ triển khai chức năng này dưới dạng module nhé:
module RequestLogger
def log_request(service, url, method = 'GET')
puts "[#{service}] #{method} #{url}"
end
end
Khi sử dụng trong Rails, bạn chỉ cần đơn giản là và class này và thay puts
bởi Rails.logger
thôi là được rồi. Sử dụng module này cho BlogService
của chúng ta nhé:
class BlogService
include RequestLogger
# ...
def posts
url = "#{config.base_url}/posts"
log_request(BlogService.name, url)
response = Net::HTTP.get_response(URI(url))
# ...
end
end
Bây giờ, chúng ta cần xử lý việc gửi request đi để lấy dữ liệu và xử lý kết quả trả về nhé:
class RequestHandler
ResponseError = Class.new(StandardError)
def send_request(url, method = :get)
response = Net::HTTP.get_response(URI(url))
raise ResponseError if response.code != '200'
JSON.parse(response.body)
end
end
RequestHandler
thực hiện 1 truy vấn HTTP đến url
được truyền vào và đưa chuỗi JSON về dạng mảng các hash luôn.
Cùng đưa class trên vào BlogService
:
class BlogService
# ...
def posts
url = "#{config.base_url}/posts"
log_request(BlogService.name, url)
posts = request_handler.send_request(url)
# ...
end
private
# ...
def request_handler
@request_handler ||= RequestHandler.new
end
end
Và cuối cùng, ta cần tạo class ResponseProcessor
để xử lý kết quả lấy được từ RequestHandler
như sau:
class ResponseProcessor
def process(response, entity)
return entity.new(response) if response.is_a?(Hash)
if response.is_a?(Array)
response.map { |h| entity.new(h) if h.is_a?(Hash) }
end
end
end
class ResponseProcessor
có thể xử lý cho cả dữ liệu trả về là dạng mảng hoặc chỉ đơn thuần là 1 hash.
Đưa ResponseProcessor
vào sử dụng trong BlogService
:
class BlogService
# ...
def posts
url = "#{config.base_url}/posts"
log_request(BlogService.name, url)
posts = request_handler.send_request(url)
response_processor.process(posts, Post)
end
private
# ...
def response_processor
@response_processor ||= ResponseProcessor.new
end
end
Cuối cùng thì phương thức posts
đã trở nên gọn gàng và thanh thoát hơn rất nhiều so với ban đầu đúng không ạ.
Hãy xem đoạn code của chúng ta sau khi tách lớp ban đầu ra thành nhiều lớp con khác nhau nhé:
require 'net/http'
require 'json'
module RequestLogger
def log_request(service, url, method = :get)
puts "[#{service}] #{method.upcase} #{url}"
end
end
class RequestHandler
ResponseError = Class.new(StandardError)
def send_request(url, method = :get)
response = Net::HTTP.get_response(URI(url))
raise ResponseError if response.code != '200'
JSON.parse(response.body)
end
end
class ResponseProcessor
def process(response, entity)
return entity.new(response) if response.is_a?(Hash)
if response.is_a?(Array)
response.map { |h| entity.new(h) if h.is_a?(Hash) }
end
end
end
class BlogServiceConfig
def initialize(env:)
@env = env
end
def base_url
return 'https://prod.myserver.com' if @env == 'production'
'https://jsonplaceholder.typicode.com'
end
end
class BlogService
include RequestLogger
def initialize(environment = 'development')
@env = environment
end
def posts
url = "#{config.base_url}/posts"
log_request(BlogService.name, url)
posts = request_handler.send_request(url)
response_processor.process(posts, Post)
end
private
attr_reader :env
def config
@config ||= BlogServiceConfig.new(env: @env)
end
def request_handler
@request_handler ||= RequestHandler.new
end
def response_processor
@response_processor ||= ResponseProcessor.new
end
end
class Post
attr_reader :id, :user_id, :body, :title
def initialize(attributes = {})
@id = attributes['id']
@user_id = attributes['user_id']
@body = attributes['body']
@title = attributes['title']
end
end
blog_service = BlogService.new
puts blog_service.posts.inspect
Như vậy, tất cả các lớp mà chúng ta tạo ra đều có trách nhiệm ít hơn. Điều quan trọng đó là chúng có thể tái sử dụng chúng khá là đơn giản.
Cảm ơn bạn đã quan tâm đến bài viết!
Tham khảo: http://rubyblog.pro/2017/05/solid-single-responsibility-principle-by-example
All rights reserved