Пишем код правильно. SOLID в действии

Пишем код правильно. SOLID в действии

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

В моей практике часто приходится видеть, что начинающие программисты прекрасно решают поставленную задачу, их код работает, но значит ли это, что код написан хорошо?

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

На каждом углу в интернете мы встречаем аббревиатуру SOLID, иногда с абстрактными сухими терминами, иногда с примерами, но почти нигде не описывается, почему нужно делать именно так. В этой статье я постараюсь не только объяснить правила SOLID, но и показать, почему им стоит следовать.

SOLID принципы:

Давайте для начала перечислим 5 принципов солид:

  • [ 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 контейнерам (инверсия управления).

Спасибо всем, кто прочитал про солид программирование, встретимся на Java курсе онлайн.