Математическое доказательство ненужности service-layer на бэкенде при взаимодействии через RPC

Введение

Так устроена человеческая психика, зачастую мы воспроизводим паттерны совершенно не задумываясь об их необходимости. Мы делаем это на основе предыдущего опыта. Опыт закрепляется чтобы более не тратить ресурс на постоянное перевычисление ответов. Нам выгодно соглашаться с опытом, особенно когда этот опыт социально-одобряемый, как например singleton или service-layer паттерн. Не смотря на то, что опыт часто представляет из себя беспонтовый пирожок, атаки на него крайне опасны! Однако, кто не рискует — тот не пьет шампанского! Приступим.

61636ff3798b2f9a767d2eb23403a4ad.jpeg

Генезис сервисов

Предположу, что сервисы являются наследием MVC архитектуры. Тогда, когда в контроллерах происходило управление представлением, бизнес логика над доменами выносилась в сервисы. В этом был смысл потому что управление представлением могло быть достаточно сложным, а работа с доменной моделью имеет отличную от представления семантику. Это также формулируют через принцип single-responsibility. Но то было давно, а сегодня никто на бэкенде состояние web-страницы не контролирует.

Что будем понимать под RPC

  • унифицированный формат запроса и ответа

  • унифицированная обработка ошибок

  • использование только POST, иногда GET для кеширования на клиентской стороне

  • URL эквивалентен имени метода на стороне бэкенда

  • API строится не вокруг «сущностей», а вокруг процедур на удаленном сервере

  • не используются path-variable

пример на Spring Web

@RestController("/employee_api") class EmployeeApi {
  
    static class DepartmentNotFound extends RpcException {
        public DepartmentNotFound(long departmentId) {...}
    }
  
    record CreateEmployeeRequest(
        String name, BigDecimal salary, long departmentId
    ) { }

    @PostMapping("/create") // Тут спрингу нужна аннотация
    long create(@RequestBody CreateEmployeeRequest emp) { // тут тоже аннотация может быть не нужна
        // for example
        if (departmentNotExists(emp.departmentId))
            throw new DepartmentNotFound(emp.departmentId);
        return newEmployeeId;
    }

    @PostMapping("/get_average_salary")
    BigDecimal getAverageSalary(@RequestBody long departmentId) {
        // ...do something
        return avgSalary;
    }

    @PostMapping("/upload_vc")
    public void uploadVc(@RequestParam("file") MultipartFile file) { //тут тоже не нужна
        // ...
    }
}

здесь делаем унифицированную обработку ошибок

@ControllerAdvice class RpcExceptionHandler {
    public static class RpcException extends RuntimeException {};

    record RpcError(String errorClass) {} // Самый простой вариант

    @ExceptionHandler(RpcException.class)
    ResponseEntity handleException(RpcException e) {
        return new ResponseEntity<>(new RpcError(e.getClass().getSimpleName()), HttpStatus.OK);
    }
}

Доказательство

Мы будем опираться на два показателя: количество действий для реализации и издержки на поддержку абстракций.

  1. Количество действий для реализации. Например сколько нужно действий чтобы создать метод сервиса, зарефакторить эндпоинт и т.д.

  2. Издержки на поддержку абстракций. Читать, дебажить, думать лишний раз над service-layer.

Будем считать все метрики нормированными относительно cost (цены) и поэтому будем писать в одну сумму. Метрики будем рассматривать в рамках одного нового эндпоинта.

В жизненном цикле программы возможны следующие события:

  • RPC1 — вы знаете, что вы просто реализуете программу на каком-то веб-фреймворке. Миграция на другой веб-фреймворк не планируется

  • RPC1?→ RPC2 — потенциально может быть переезд на другой веб-фреймворк

  • RPC1?→ {RPC1, RPC2} — потенциально вам может понадобиться второй транспорт для той же бизнес логики (например бинарный протокол)

  • {RPC1, RPC2} — 2 транспорта для одной бизнес логики. Рассматривать не будем, очевидно если вы это знаете, то сразу делаете service-layer

Ниже будем использовать следующие обозначения:

  • C — стоимость написания эндпоинта

  • DL — стоимость написания доменной логики

  • E — Extra costs (издержки)

  • S — стоимость написания сервиса

  • I — случайная величина. I = 1, когда случился рефакторинг и I = 0 иначе. P (I=1) = p, P (I=0) = 1-p.

  • R — стоимость рефакторинга (выделения service-layer)

  • t — переменная времени, от момента написания эндпоинта

  • N — количество эндпоинтов

Ситуация RPC1

sCost = C + S + DL + E  \\ nsCost = C + DL \\  sCost - nsCost = S + E

  • sCost — стоимость разработки эндпоинта когда есть service-layer

  • nsCost — когда service-layer нет

Думаю очевидно, что делать сервис не имеет никакого смысла. Также замечу, что параметр sCost — nsCost рассчитан в рамках эндпоинта, а в рамках целой программы этот показатель нужно умножать на N. Также важно заметить, что E (издержки) зависит от t, причем монотонно возрастает по t. Почему? Потому что, например, каждый раз когда вы будете сталкиваться с этим эндпоинтом в коде, вам придется обратить свое внимание на сервис. Пока что нет необходимости углубляться в S и E.

Ситуация RPC1?→ RPC2

sCost = C + S + DL + E + I * sR \\ nsCost = C + DL + I * nsR  \\ sCost - nsCost = S + E + I * (sR - nsR)

  • sR — стоимость рефакторинга если мы написали сервисы ранее

  • nsR — если ранее сервисы мы не писали

Рассмотрим sR — nsR. В случае переезда с RPC1 на RPC2 в обоих случаях (когда сервисы есть и когда их нет) вам придется просто поменять аннотации на эндпоинтах. То есть sR — nsR = 0, что приводит нас к предыдущему варианту. Следовательно, смысла создавать сервисы нет.

Cледствие 1

Писать сервисы всегда хуже, чем не писать, если вы знаете что у вас будет только один транспорт поверх одной доменной логики!

Ситуация RPC1?→ {RPC1, RPC2}

sCost = C + S + DL + E(t) + I * sR \\ nsCost = C + DL + I * nsR \\ nsCost - sCost = -S - E - I * (sR - nsR) \\ profit = M(nsCost - sCost) = - S - E(t) + p * (nsR - sR) > 0» src=«https://habrastorage.org/getpro/habr/upload_files/4cb/85b/e46/4cb85be46ca445b491dae1c5e1c857c0.svg» /></p>

<p>Здесь profit — ожидаемый (средний) выигрыш (cost) от использования сервисов. Далее для сокращения будем обозначать nsR — sR = R > 0. Также, будем считать, что издержки линейно растут от t, то есть E (t) = Kt</p>

<p><img alt= \frac{S + E (t)}{R} \\ p (t)> \frac{Kt}{R} + \frac{S}{R}» src=«https://habrastorage.org/getpro/habr/upload_files/fef/cf0/d65/fefcf0d6588b9348136c0c32006f9777.svg» />

Мы получили нижнюю границу для вероятности рефакторинга (появление дополнительного транспорта) в момент времени t такую, при которой нам выгодно заранее написать сервисы.

Здесь S/R — минимальная вероятность рефакторинга, в том числе в момент времени 0.

Заметим, что p (t) строго монотонна (K!= 0). Мы можем найти момент времени Tx такой, что p (Tx) = 1

1 = \frac{KTx}{R} + \frac{S}{R} \\ Tx = \frac{R-S}{K}

Чем больше тикает времени — тем выше должна быть вероятность рефакторинга. К моменту Tx накладные расходы на сервис превысят стоимость рефакторинга!

Все рассуждения, конечно, приведены статистически, т.к. ранее мы брали математическое ожидание. Перейдем к самому интересному — оценкам!

Следствие 2

Моя статистика говорит, что S ≈ R. Но возьмем ультраконсервативную оценку в S = R/5. При этом R = 5 минут. Далее, очевидно что один разработчик будет работать с сервисом одного эндпоинта не менее 10 секунд в день (подумать написать ли тесты к методу сервиса, прокликать пакеты, проверить что он делает, сгореть от того что он есть). Далее возьмем среднюю команду в 3 человека, значит K = 30 секунд в день. Получаем:

Tx = 8 \text{ рабочих дней.}

Время пошло!

© Habrahabr.ru