Пишемо код правильно. SOLID в дії

Пишемо код правильно. SOLID в дії

  • 22 січня, 2021
  • читати 15 хв
Артур Мамедов
Артур Мамедов Senior Java Developer у Playtika

У моїй практиці часто доводиться бачити, що програмісти-початківці прекрасно вирішують поставлене завдання, їх код працює, але чи означає це, що код написаний добре?

Насправді ні. Проблема не стільки в тому, що вони не хочуть або не мають достатній обсяг знань, а в тому, що їм ніхто ніколи не показував, як треба, і найголовніше — навіщо.

На кожному розі в інтернеті ми зустрічаємо абревіатуру SOLID, іноді з абстрактними сухими термінами, іноді з прикладами, але майже ніде не описується, чому потрібно робити саме так.

У цій статті я постараюся не тільки пояснити правила SOLID, а й показати, чому їм варто слідувати.

Давайте для початку перерахуємо 5 принципів SOLID:

  • [ S ] Single-responsibility principle — принцип єдиної відповідальності

  • [ O ] Open-closed principle — принцип відкритості-закритості

  • [ L ] Liskov Barbara principle — принцип Барбари Лісков

  • [ I ] Interface segregation principle — принцип поділу інтерфейсів

  • [ D ] Dependency inversion principle — принцип інверсії залежностей

Особливу увагу слід приділити принципам під буквами S, O, D, саме їх ми розкриємо і на них будемо спиратися в цій статті. Важливо відразу розуміти, що SOLID — це набір вимог, але не відповідей на те, як ці вимоги задовольнити.

Хочеться сказати, що основним критерієм до сучасних додатків є їх гнучкість до змін. Адже колись додатки писалися раз, після чого про них забували, але зараз ситуація така, що якщо в додатку не народжуються нові фічі, то його місце легко займають конкуренти.

Давайте для початку розглянемо задачу і її робоче рішення без огляду на 5 принципів SOLID.

Власне завдання: збереження замовлення в інтернет-магазині.

Припустимо, початковим вимогою замовника було в момент замовлення просто зберігати запис в базу даних. Відразу скажу, що для роботи з базою ми будемо використовувати деякий вигадане бібліотечне рішення, нехай воно буде представлено якимось класом DBUtil (для спрощення).

У підсумку виходить досить простий і читається код, чи не так?

public class OrderService {
    private DBUtil db;
    public OrderService(){
        db = DBUtil.create(Config.getDbConfig());
    }

    public void createOrder(Order o){
        Object[] params = {
            o.getUserId(),
            o.getProductId(),
            o.getProductCount()
        };

        db.execQuery(
            "INSERT INTO orders (user_id,product_id,product_count) VALUES (?,?,?)",
            params
        );
    }
}

Представлений код працює?

- Так.

Вирішує поставлене завдання?

- Так.

Але чи можна сказати що це хороший код?

Давайте розберемося, що з ним не так.

Для цього давайте спочатку представимо, що наш замовник захотів щоб ми, крім запису в базі, відправляли повідомлення на пошту.

Що буде робити початківець розробник? Правильно, він буде нарощувати метод createOrder, в результаті чого код стане виглядати якось так:

public class OrderService {
    private DBUtil db;
    private Mailer mailer;

    private final static String BOSS_EMAIL = "boss@example.com";

    public OrderService(){
        db = DBUtil.create(Config.getDbConfig());
        mailer = new Mailer(Config.getSmtpConfig());
    }

    public void createOrder(Order o){
        Object[] params = {
            o.getUserId(),
            o.getProductId(),
            o.getProductCount()
        };

        db.execQuery(
            "INSERT INTO orders (user_id,product_id,product_count) VALUES (?,?,?)",
            params
        );

        String message = "Создан заказ от пользователя "
            +o.getUserId()
            +" на товар "+o.getProductId()
            +" в количестве "+o.getProductCount();

        mailer.sendMessage(BOSS_EMAIL,message);
    }
}

Що ж ми бачимо — метод createOrder істотно збільшився в розмірах і читати його стало вже не так просто.

А тепер уявіть, що додасться ще пара побічних дій. У цьому випадку даний метод стане просто величезним, крім того, в разі необхідності буде все складніше і складніше нарощувати функціонал. А якщо, наприклад, замовник захоче змінити базу даних або спосіб відправки повідомлення (наприклад в Telegram або Viber), то доведеться шукати серед досить чималої кількості коду той, який відповідає за відправку або запис, а ці операції можуть бути не тільки в цьому сервісі. Маємо дуже багато редагування, витрачаємо багато часу і точно помилимося при зміні коду.

Для вирішення даної проблеми слід для початку керуватися буквою S з абревіатури SOLID, тобто принципом єдиної відповідальності. Цей принцип означає, що у однієї одиниці (об'єкта, методу) повинна бути тільки одна-єдина задача.

Наш клас OrderService виконує досить багато різних завдань, а саме:

  • створення і настройка допоміжних об'єктів, які є його полями (далі будемо називати їх залежностями)

  • вставка даних в базу

  • формування і відправлення повідомлень

Давайте спробуємо дотримати принцип єдиної відповідальності, хоча б частково.

Що ж нам для цього потрібно? Необхідно рознести роботу з базою і відправку повідомлень в окремі класи, назвемо їх відповідно OrderDatabaseRepository і OrderMailNotifier.

//первый класс, осуществляющий работу с базой
public class OrderDatabaseRepository {
    private DBUtil db;

    public OrderDatabaseRepository(){
        db = DBUtil.create(Config.getDbConfig());
    }

    public void saveOrder(Order o){
        Object[] params = {
            o.getUserId(),
            o.getProductId(),
            o.getProductCount()
        };

        db.execQuery(
            "INSERT INTO orders (user_id,product_id,product_count) VALUES (?,?,?)",
            params
        );
    }
}

//второй класс, отправляющий уведомления
public class OrderMailNotifier {
    private Mailer mailer;
    private final static String BOSS_EMAIL = "boss@example.com";

    public OrderMailNotifier(){
        mailer = new Mailer(Config.getSmtpConfig());
    }

    public void sendBossNotification(Order o){
        String message = "Создан заказ от пользователя "
            +o.getUserId()
            +" на товар "+o.getProductId()
            +" в количестве "+o.getProductCount();

        mailer.sendMessage(BOSS_EMAIL,message);
    }
}

Ми винесли завдання в окремі класи, кожен з яких став маленьким, легко читаним і виконує тільки одну задачу (насправді поки не одну, але про це мова піде далі).

У підсумку наш сервіс придбає такий вигляд:

Рекомендуємо курс по темі

public class OrderService {
    private OrderMailNotifier notifier;
    private OrderDatabaseRepository repository;

    public OrderService(){
        notifier = new OrderMailNotifier();
        repository = new OrderDatabaseRepository();
    }

    public void createOrder(Order o){
        repository.saveOrder(o);
        notifier.sendBossNotification(o);
    }
}

Подивіться, наскільки простіше став клас OrderService, адже метод createOrder простий і компактний, всього 2 рядки, в цьому коді складно заплутатися. Рішення окремих завдань тепер теж рознесено по окремих класах, кожен з яких має невеликий розмір, і зрозуміти його роботу значно простіше, ніж одного величезного.

Але наш код все ще не позбавлений великої кількості недоліків.

По-перше, наші класи все так само мають побічну відповідальність - створення своїх залежностей, по-друге якщо у нас, наприклад, зміниться спосіб відправки повідомлень, то, крім написання нового нотіфікатор, нам також доведеться змінювати код в OrderService, який його використовує.

Так як використовувати нотіфікатор насправді можна не тільки тут, значить код доведеться міняти всюди, де він написаний.

Настала пора поговорити про принцип відкритості-закритості (буква O в абревіатурі SOLID), він говорить, що наш код повинен бути відкритий для розширення, але закритий для редагування.

Проектуючи це на нашу задачу, принцип говорить, що ми не повинні міняти код класу OrderService для зміни способу відправки повідомлення або при зміні сховища.

З цим принципом тісно пов'язаний принцип, позначений буквою D — інверсія залежностей, його розуміння допоможе нам написати такий код, який відповідає і принципу відкритості-закритості. Цей принцип говорить нам, що абстракції не повинні залежати безпосередньо від своїх деталей, а повинні залежати тільки від їх абстрактних уявлень (інтерфейсів).

Відповідно наш клас OrderService не повинен залежати від більш дрібних OrderMailNotifier і OrderDatabaseRepository безпосередньо, а повинен залежати тільки від інтерфейсів, які вони реалізують.

Так як таких інтерфейсів немає, давайте напишемо їх:

public interface OrderRepository {
    void saveOrder(Order o);
}


public interface OrderNotifier {
    void sendBossNotification(Order o);
}

У класі OrderService ми повністю позбавляємося від реалізацій, створювати екземпляри інтерфейсів ми не можемо, тому будемо просто приймати їх в якості аргументів конструктора:

public class OrderService {
    private OrderNotifier notifier;
    private OrderRepository repository;

    public OrderService(OrderNotifier notifier,OrderRepository repository){
        this.notifier = notifier;
        this.repository = repository;
    }

    public void createOrder(Order o){
        repository.saveOrder(o);
        notifier.sendBossNotification(o);
    }
}

Тепер для зміни способу відправки повідомлення або зберігання даних нам досить буде зробити нову реалізацію відповідного інтерфейсу і передати екземпляр цієї реалізації в конструктор сервісу, при цьому зміна самого сервісу не буде потрібно. Це означає, що ми дотрималися як принцип інверсії залежностей, так і принцип відкритості-закритості.

Давайте не будемо забувати про класи OrderDatabaseRepository і OrderMailNotifier і зробимо їх імплементації відповідних інтерфейсів, але не слід забувати що у них теж є залежності (Mailer і DBUtil). Так як ми вважаємо, що це бібліотечні класи (а можливо і інтерфейси) то ми просто зробимо їх полями класу, але не будемо створювати самостійно, просто приймемо як аргументи конструктора.

Таким чином ми як мінімум знизимо ступінь залежності і зніміть з наших класів зайву відповідальність за створення своїх залежностей:

//первый класс, осуществляющий работу с базой
public class OrderDatabaseRepository implements OrderRepository{
    private DBUtil db;

    public OrderDatabaseRepository(DBUtil db){
        this.db = db;
    }

    @Override
    public void saveOrder(Order o){
        Object[] params = {
            o.getUserId(),
            o.getProductId(),
            o.getProductCount()
        };

        db.execQuery(
            "INSERT INTO orders (user_id,product_id,product_count) VALUES (?,?,?)",
            params
        );
    }
}

//второй класс, отправляющий уведомления
public class OrderMailNotifier implements OrderNotifier{
    private Mailer mailer;
    private final static String BOSS_EMAIL = "boss@example.com";

    public OrderMailNotifier(Mailer mailer){
        this.mailer = mailer;
    }

    @Override
    public void sendBossNotification(Order o){
        String message = "Создан заказ от пользователя "
            +o.getUserId()
            +" на товар "+o.getProductId()
            +" в количестве "+o.getProductCount();

        mailer.sendMessage(BOSS_EMAIL,message);
    }
}

Цей код хоч і дещо більше вихідного, але кожен його елемент окремо значно простіше, крім того, цього такий код куди менш болісно переносить всілякі зміни, ви тільки міняєте окремі його частини, які самі по собі досить невеликі. Коли ви пишете кожен окремий клас, ви зосереджуєтесь виключно на тому завданню, яку необхідно виконати.

На жаль, не все, що хотілося б розповісти і показати, можна помістити в одній статті, наприклад, давайте поглянемо на код, який створює OrderService.

Він може виглядати так:

DBUtil db = DBUtil.create(Config.getDbConfig());
    Mailer mailer = new Mailer(Config.getSmtpConfig());

    OrderNotifier notifier = new OrderMailNotifier(mailer);
    OrderRepository repository = new OrderDatabaseRepository(db);

    OrderService orderService = new OrderService(notifier,repository);

Цей код не поганий, але і не хороший. Про те, як зробити його краще, ми і поговоримо в наступній статті, присвяченій IoC контейнерів (інверсія управління).

Спасибі всім, хто дочитав до цього місця.