Проще - лучше

August 2, 2021

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

Я последнее время начинаю думать, что лучше писать максимально “дубово” и просто, чем наворачивать кучу слоев абстракций в надежде на будущую универсализацию. А также всегда обязательно думать головой и не переусложнять без нужды. Пойдем по пунктам. Примеры буду приводить на Go, т.к. больше пишу на нем, но в той или иной степени это касается любого языка.

1. Нужно отделять интерфейс от реализации!

Так как так универсальнее/пишут в умных статьях/я типа клевый программист/еще какое-то оправдание.
Мой ответ - нет. Не нужно. Если быть точнее, создавать интерфейс нужно только тогда, когда у вас:

  • есть две или больше сущности с одинаковыми методами, но разной реализацией
  • требуется делать подмену реализации для юнит-тестов
  • есть какой-то Dependency Injection с обязательным использованием интерфейсов

Если ни один из этих пунктов еще не работает - не надо впиливать интерфейс заранее! Пожалуйста. Он серьезно замедляет чтение и понимаение вашего кода другим разработчиком, а бонусов никаких не приносит.

Поясню на примере, что я имею в виду. Вот запилили мы прекрасную сущность JsonReader с методом Parse, который принимает на вход имя файла, а на выходе возвращает некую map (словарь в go) из него:

type JsonReader struct {
}

func (r JsonReader) Parse(filename string) map[string]interface{} {
	//do something
	return nil
}

Так вот - если у вас прямо сейчас нет каких-нибудь YamlReader, IniReader, XmlReader, SomeFormatReader и вы никак не подменяете JsonReader в тестах - не надо лепить сюда еще и интерфейс Reader типа такого:

type Reader interface {
	Parse(filename string) map[string]interface{}
}

Пусть останется JsonReader-ом до появления своих “братьев”. Так будет лучше для всех - и для вас, когда вы через полгода вновь откроете проект и для вашего коллеги, который откроет проект через год после вашего ухода. И проект можно просматривать в блокноте без необходимости через F12 прыгать по реализациям.

На самом деле в этом плане меня сильно расстраивает .NET.Core, т.к. в нем контроллеры, сервисы и т.д. вы просто обязаны делать интерфейсами, иначе не будет работать встроенный DI.

2. А давайте сделаем возможность в любой момент менять базу данных!

С postgres на mysql, oracle, mongo…
Только один вопрос - сколько раз в своей жизни вам приходилось менять СУБД в уже работающем проекте? Мне - ровно ноль.

Обычно под этим соусом делают лишний слой абстракций, который инкапсулирует в себе работу с БД. Но так как БД вы менять не будете (а если будете - все равно вылезет куча нюансов и проблем, так что переписывать часть проекта и так придется) - этот код пылится мертвым грузом и опять же ухудшает читаемость проекта для всех, кто его открывает.

Я не предлагаю вызывать методы конкретного фреймворка или библиотеки прямо в контроллере (так же как и писать SQL-запросы там), т.е. я считаю нормальным вынести какой-нибудь метод типа GetItem отдельно в другой пакет, внутри которого уже будет вызываться БД напрямую:

type Item struct {
	ID   int
	Name string
	Text string
}

func GetItem(id int) *Item {
	item := orm.GetBySQL("SELECT * FROM items WHERE id=?", id)
	return item
}

func GetItemHandler(c *someHttp.context) {
	id := c.Param("id")
	item := service.GetItem(id)
	c.JSON(200, item)
}

Но я против того, чтобы мы делали некий полиморфизм для разных БД внутри этого метода GetItem:

func GetItem(id int) *Item {
	switch provider.DbType {
	case "postgresql":
		return ps.GetBySQL("SELECT * FROM items WHERE id=?", id)
	case "otherdb":
		return otherdb.GetBySQL("SELECT * FROM items WHERE id=?", id)
	}
	panic("unknown db")
}
3. Юнит-тесты! (и тесты вообще)

Ведь мы же топовые пацаны и должны писать тесты с большим покрытием, как везде говорят!
Тут у меня две позиции, начну с первой.

3.1 Что дают юнит-тесты и не лучше ли писать другой вид тестов? По сути юнит-тестами мы проверяем только то, что наш модуль/функция/класс работают так, как мы запланировали, при изолированном окружении. Т.е. вот пишем мы метод расчета какой-нибудь суммы для клиента, которая принимает на вход три параметра, а на выходе выдает один. Пишем юнит-тест для нее и для всех аналогичных функций. Все отлично, ~90% покрытие, вы великолепны. А потом на проде где-нибудь приходит неожиданный ответ от другого сервиса на вход и все отваливается. Либо вам нужно переделать эту функцию на две разных с разным набором параметров, и нужно в дополнение к коду переписывать еще и тесты.

В общем, я веду к тому, что юнит-тесты не дают нам практически никаких бонусов в итоге, а лишь одни минусы. Вот серьезно, ну написал я функцию с ошибкой в том, что при определенном наборе входных параметров у меня где-то будет паника или деление на 0. А при этом такой набор вообще невозможен исходя из логики сервиса? Не пофиг ли, какие там ошибки внутри в реализации, если на всем множестве кейсов весь сервис отрабатывает корректно и деление на 0 не вызывается никогда?

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

Т.е. если я пишу сервис с одним GET-методом, который принимает на вход id записи в базе и возвращает ее в JSON, я должен написать тесты, которые проверяют, например:

  • что при наличии записи в БД с таким id она возвращается наружу
  • что при отсутствии записи в БД возвращается ошибка
  • что при некорректном id на входе возвращается ошибка.

Все. Это все. Я не должен тратить свое время на тестирование того, что где-то там во внутренностях сервиса ничего не делится на 0, выдает ошибку при отрицательном id и не падает при отсутствии конфига/инстанса БД.
Как называются эти тесты? Я без понятия, вероятно, интеграционные :)

Разумеется, есть моменты, когда можно и нужно тратить время и на написание юнит-тестов с полным покрытием:

  • если мы разрабатываем не конечное приложение, а библиотеку или SDK
  • если у нас много разработчиков, которым нечем заняться.

3.2 Когда можно вообще не писать тесты? Если мы примерно или точно рассчитали стоимость и необходимость написания и поддержки тестов и решаем, что скорость разработки на данном конкретном этапе жизненного цикла проекта важнее. Т.е. если мы разрабатываем сервис для нового стартапа, у которого уже есть конкуренты и скорость выхода на рынок/реализации новых фич критична, а количество багов и потери репутации/средств из-за них не столь существенны - то есть смысл на какое-то время не тратить время на написание тестов, да, как это ни ужасно.

Если же мы не спеша релизим новые фичи для сервиса международных переводов, у нас три конкурента-мастодонта, а на каждую небольшую ошибку поднимается вой в СМИ нескольких стран/штрафы/потери денег/отток клиентов - то тут действительно есть смысл написать тесты по-максимуму на все участки, чтобы сократить количество ошибок.

При написании тестов (как и в других вещах) нужно выбирать правильную стратегию и думать головой, а не слепо следовать модным веяниям и тенденциям.

4. HTTP для передачи данных между микросервисами слишком медленный/неудобный/старый/немодный, давайте будем отправлять все по gRPC с protobuf!

На мой взгляд, менять HTTP как протокол на что-то другое следует только когда:

  • запросы от клиента до конечного сервиса идут слишком долго из-за слишком большого количества вызовов по пути
  • данных передается очень много и экономия (в деньгах!) при их упаковке может принести больше $1000 в месяц

Поясню свою позицию. HTTP разработан достаточно давно и сейчас его поддерживает практически все. Дебажить его достаточно просто, а посмотреть, что же там все-таки у нас уходит куда-то или приходит откуда-то - тоже проще простого. С переходом на другой протокол вы теряете эту роскошь и часть контроля над происходящим. А также новые сервисы разрабатывать уже чуть сложнее, + к технологиям, которые надо знать, чтобы вообще начать работать с вашим проектом. Если вас и ваших клиентов устраивает скорость работы и объем передаваемых данных - то не нужно ничего менять.

Продолжение

следует


comments powered by Disqus