- Час: 20-30 min
- Рівень: Beginner/Intermediate
- Код: GitHub
Перед тим як почати писати дану статтю я спробував знайти інших людейякі намагались розглянути код в даному ракурсі - знайшов наступний пост How deep is your code?
Мені потрібен був приклад складного Service Object ія знайшов його в репозиторії Discourse файл. Я в жодному разіне критикую даний код, більше того в тому ж самому репозиторії я знайшов чудовий прикладтого що я намагаюсь описати файл
Особисто я надаю перевагу маленьким класам, коротким, single purpose методам,а не одному великому шматку коду. Проте мушу визнати що даний підхідмає значний недолік - досить часто методи короткі лише тому що вони викликають інші методи, які викликають інші методи,які викликають інші методи… Отож, перш ніж я зможу побачити що насправді відбувається,мені доводиться переглядати купу методів і досить часто я вже не пам’ятаю з чого починав, колинарешті знайду те що шукав.
Я не хочу цим займатись, я не хочу переглядати 5-10 приватних методів щобмати уявлення якими будуть наслідки виклику одного публічного методу.я хочу щоб метод одразу, з першого погляду давав зрозуміти що саме відбувається,можливо є спосіб цього досягнути?
Одного разу я бачив метод оцінки складності коду, який описала Sandi Metz в своєму виступі All the Little Things - the Squint Test.Не зважаючи на те, що даний метод був застосований для оцінки вкладених conditionals, миможемо розглянути виклик методів у схожому контексті. Якщо метод викликає інший методдавайте ми зробимо відступ зліва, так ніби ми маємо вкладений if:
def parent
child_level_two
# some code
end
def child_level_two
# some code
child_level_three
end
def child_level_three
# some code
child_level_four
# some code
end
тепер давайте запишемо всі задіяні методи і зробимо відступи для кожного вкладеного виклику
def parent
child_level_two
child_level_three
child_level_two
child_level_three
child_level_three
child_level_two
child_level_two
child_level_three
child_level_three
child_level_four
child_level_two
child_level_two
child_level_three
child_level_four
child_level_four
end
ми отримали схожу фігуру, що більші нерівною є права її частина - тобільше вкладених викликів ми маємо.
КРОК #1
Зміни в коді можна подивитись тут
Service Object перед refactoring, я виніс зовнішні залежності в dependencies.rb
і додав placeholders для задіяних методів
# user_updater.rb
require 'active_support'
require 'active_support/core_ext/object/blank'
require 'active_support/core_ext/time/calculations'
require 'active_model'
require_relative 'dependencies'
class UserUpdater
delegate :change_post_owner, to: :guardian
def update(attributes = {})
save_options = false
saved = nil
old_user_name = user.name.present? ? user.name : ""
user_profile = user.user_profile
user_profile.location = attributes.fetch(:location) { user_profile.location }
user_profile.dismissed_banner_key = attributes[:dismissed_banner_key] if attributes[:dismissed_banner_key].present?
user_profile.website = format_url(attributes.fetch(:website) { user_profile.website })
user_profile.profile_background = attributes.fetch(:profile_background) { user_profile.profile_background }
user_profile.card_background = attributes.fetch(:card_background) { user_profile.card_background }
if SiteSetting.enable_sso && !SiteSetting.sso_overrides_bio
user_profile.bio_raw = attributes.fetch(:bio_raw) { user_profile.bio_raw }
end
user.name = attributes.fetch(:name) { user.name }
user.locale = attributes.fetch(:locale) { user.locale }
user.date_of_birth = attributes.fetch(:date_of_birth) { user.date_of_birth }
if can_grant_title?(user)
user.title = attributes.fetch(:title) { user.title }
end
# special handling for theme_key cause we need to bump a sequence number
if attributes.key?(:theme_key) && user.user_option.theme_key != attributes[:theme_key]
user.user_option.theme_key_seq += 1
end
OPTION_ATTR.each do |attribute|
if attributes.key?(attribute)
save_options = true
if [true, false].include?(user.user_option.send(attribute))
val = attributes[attribute].to_s == 'true'
user.user_option.send("#{attribute}=", val)
else
user.user_option.send("#{attribute}=", attributes[attribute])
end
end
end
# automatically disable digests when mailing_list_mode is enabled
user.user_option.email_digests = false if user.user_option.mailing_list_mode
fields = attributes[:custom_fields]
if fields.present?
user.custom_fields = user.custom_fields.merge(fields)
end
User.transaction do
if user.user_option.save && user_profile.save && user.save
StaffActionLogger.new(@actor).log_name_change(
user.id,
old_user_name,
attributes.fetch(:name) { '' }
)
saved = true
end
end
saved
end
private
attr_reader :user, :guardian
def initialize(actor, user, guardian = Guardian.new(actor))
@user = user
@guardian = guardian
@actor = actor
end
def format_url(website)
return nil if website.blank?
website =~ /^http/ ? website : "http://#{website}"
end
end
КРОК #2
Даний крок поділено на кілька невеликих частин:
- #2.1 Refactor
UserUpdater#update
метод (commit) - #2.2 Refactor методи що були додані в 2.1
Я пропущу деталі, тому що зміни є досить прості і звичні для refactoring, вони були зроблені щоб спроститиUserUpdater#update
метод, зробити його коротким і простим для розуміння.
# user_updater.rb
class UserUpdater
delegate :change_post_owner, to: :guardian
def update(attributes = {})
old_user_name = user.name.present? ? user.name : ""
update_user_profile( user.user_profile, attributes )
update_user( user, attributes )
update_user_option( user.user_option, attributes )
save_user_data(user, old_user_name, attributes)
end
private
# ...
Проте я вважаю, що тепер надто багато інформації прихованоЯкщо подивитись на тіло методу ми більше не бачимо що:
- В методі використовується транзакція
- Деякі частини змінюються лише за виконання певних умов
- Ми покладаємось на константу, коли визначаємо які дані змінити
- Не очевидно що ми повертаємо (і використовуємо значення)
true
/false
в залежності від результату транзакції
In addition to hiding information, I storngly dislike how newОкрім прихованої інформації, мені надзвичайно не подобається як виглядаєUserUpdater#update_user_profile
- це приватний метод, якийскладається лише викликів інших приватних методів і приховує існуючі conditionals.
# user_updater.rb
def update_user_profile( user_profile, attributes )
update_geo_data( user_profile, attributes )
update_web_data( user_profile, attributes )
update_background_data( user_profile, attributes )
update_bio_raw( user_profile, attributes )
end
Я вважаю що схожі методи лише підвищують складність кодуі не мають права існувати.
КРОК #3
Зміни в коді можна подивитись тут
На цьому кроці ми позбудемось приватних методів, що були (майже) лишеобгортками навколо інших приватних методів і трохи змінимо назви методів, що залишились,щоб краще відобразити їх мету
# user_updater.rb
class UserUpdater
delegate :change_post_owner, to: :guardian
delegate :log_user_name_change, to: :StaffActionLogger
def update(attributes = {})
old_user_name = user.name.present? ? user.name : ""
user_profile = user.user_profile
user_option = user.user_option
set_user_profile_geo_data( user_profile, attributes )
set_user_profile_web_data( user_profile, attributes )
set_user_profile_background_data( user_profile, attributes )
set_user_profile_bio_raw( user_profile, attributes )
set_user_bio(user, attributes, update_title: can_grant_title?(user) )
user.custom_fields = user.custom_fields.merge( attributes.fetch( :custom_fields, {} ) )
set_user_option_theme_key(user_option, attributes)
OPTION_ATTR.each { |attribute| set_user_option_single_attribute(user_option, attributes, attribute) }
# automatically disable digests when mailing_list_mode is enabled
user_option.email_digests = false if user_option.mailing_list_mode
return false unless User.transaction { user.user_option.save && user.user_profile.save && user.save }
log_user_name_change( user.id, old_user_name, attributes.fetch(:name) { '' } )
return true
end
UserUpdater#update
метод став майже втричі більшим в порівнянні зпопередньою версією, проте тепер він значно краще розповідає свою історію.
Він не ідеальний (і ніколи таким не буде), проте ми зробимо ще один крок для його покращення і знову порівняємо з версією з Кроку #2
КРОК #4
Зміни в коді можна подивитись тут
На даному кроці ми ще трохи відшліфуємо наш код і порівняємо остаточні результати
# user_updater.rb
# Short version
class UserUpdater
# ...
def update(attributes = {})
old_user_name = user.name.present? ? user.name : ""
update_user_profile( user.user_profile, attributes )
update_user( user, attributes )
update_user_option( user.user_option, attributes )
save_user_data(user, old_user_name, attributes)
end
# Two levels deep version
class UserUpdater
# ...
def update(attributes = {})
@attributes = attributes
old_user_name = user.name.present? ? user.name : ""
set_user_profile_geo_data
set_user_profile_web_data
set_user_profile_background_data
set_user_profile_bio_raw if should_update_user_profile_bio_raw?
set_user_bio( update_title: can_grant_title?(user) )
user.custom_fields = user.custom_fields.merge( attributes.fetch( :custom_fields, {} ) )
set_user_option_theme_key if should_update_user_option_theme_key?
OPTION_ATTR.each do |attribute|
set_user_option_single_attribute( attribute )
end
# automatically disable digests when mailing_list_mode is enabled
user_option.email_digests = false if user_option.mailing_list_mode
return false unless User.transaction { save_user_and_related_entities }
log_user_name_change( user.id, old_user_name, attributes.fetch(:name) { '' } )
return true
end
Я вважаю що довша версія має наступні переваги:
- Код розповідає чудову історію з великою кількістю деталей виконання
- Код слідує принципу Open/Closed
- Ви завжди щонайбільше за один крок від повного визначення будь-якого приватного методу
ПІДСУМОК
Для того щоб мати глибину рівну 2 код повинен задовольняти наступні критерії:
- Публічні методи не можуть викликати інші публічні методи того ж класу
- Приватні/Захищені методи не можуть викликати інші приватні/захищені методи
У мене є ще два не обов’ язкових правила
- Уникати conditionals в приватних методах
- Conditionals повинні використовувати приватні методи а не boolean expressions
Код:
ДЛЯ РОЗДУМІВ
- Дані правила досить просто виконати при написанні Service Objects, тому що вони зазвичайописуюють певний процес/алгоритм - де ще можна застосувати їх?
- Як щодо delegated та accessor methods, чи можна їх вважати приватними при застосуванні метрики?
Top comments (1)
Цікава тема і особливо висновок в кінці, але мені важко було прослідкувати всі етапи рефакторингу. Вважаю, варто було б спростити код, можливо придумати якийсь синтетичний приклад. Так цінність статті виросла б. Відчувається, щоб була проведена непогана робота.