На моей последней проектной работе мне предложили создать решение на .net/c# с нуля. Заложить архитектуру, стандартные либы, практики, и т.д. Приложение планировалось большое, я получил море бюджета на исследование и продумывание всего.
В процессе я принял много решений. Так много, что сильно удивился — сколько же практик и подходов у меня сложилось за довольно короткую карьеру. Откуда я их взял? Как я к ним пришёл, и с хрена ли я в них верю?
Дело в том, что я не знаю, как это работает у других разработчиков, и только сегодня обстоятельно разобрался в том, как это работает у меня.
У меня в башке есть такой абстрактный «правильный разработчик», который всё делает единственно верным образом. Обычно моя задача быть немного на него похожим. Когда я пишу какой-то код, спрашиваю: «А как бы поступил правильный разраб? Что бы он выбрал и почему?». Да, его не существует, и он на самом деле никак бы не поступил, и эта мысленная сущность не помогает мне решать проблемы. Она нужна для двух вещей: заставляет задавать себе кучу вопросов и чувствовать себя куском говна каждый раз, когда хоть что-то сделал.
С годами работы и я, и мой правильный разраб стали одинаковыми, и мы почти не задаём себе вопросов. Но говном-то я себя, конечно же, считаю, видимо, по привычке.
Вот как это работает.
Мне надо спроектировать либу — http client, для специфического реста. Я такой: «Окей, круто. Возьму последний C#, потому что я всегда беру последнее всё. Включу экспериментальные фичи, потому что скорей всего они будут актуальными в момент, когда я закончу разработку. Использую JetBrains Rider, потому что он выглядит более современным, чем студия. И начну писать код».
Примерно так
public interface Api {
}
И сразу тонна вопросов. Это публичный интерфейс либы, которая представляет апи сервера лицензий. Почему он называется не `LicenseApi`, а просто `Api`?
Потому что проект называется HttpClient. А фолдер (сборка), в котором лежит интерфейс, называется License. А директория, в которой он лежит, называется Public. Я не хочу иметь название `HttpClient.License.Public.LicenseApi` — два слова `License` подряд — что хотел сказать автор? Сейчас доступ к интерфейсу будет выглядеть так: `HttpClient.License.Public.Api`. Кристально чисто.
Го форвард. В дотнете принято называть интерфейсы с префиксом I. IApi. Эта штука даже не обсуждается — сложившаяся практика. Она пришла из тех времен, когда IDE нихрена умели, и разрабы хотели быстро понять, что перед ними — интерфейс, класс. структура, указатель… Моя сегодняшняя IDE может покрасить, подчеркнуть и обжирить любой символ любого ЯП ровно так, как я этого хочу. Райдер по моему приказу пишет интерфейсы курсивом. Зачем мне префикс? Во-первых, так просто принято, и у нас в разработке принято поступать так, как принято. Я отрастил достаточно большие яйца, что бы поступать по другому, когда категорически не согласен. Во-вторых, есть практический момент — дурацкий гитхаб. Он из коробки не умеет в символы, и, просматривая сорцы там, я не пойму, где интерфейс, а где класс. Здесь мой поинт таков — ну и хрен с ним. В гитхабе разработка не ведется, там отображаются её результаты. Хочешь понимать, где что — клонируй репу, открывай IDE и смотри. Ещё показательный момент — в официальных гайдлайнах к тайпскрипту не рекомендуется использовать префикс I для интерфейсов, аргумент — это пережиток прошлого.
Лично для меня ключевой аргумент один — а чего это вы, парни, тогда не пишете префиксы для всего?
public class CApi { // C - класс
private int _some; // _ - field
public IMyType PSome { get; } // P - property
public void MDoStuff(){..} // M - method
public event EventHandler MyEvent; // event, event, event
}
Зашибись же? По листингу кода всё станет понятно. Дерзайте.
Нет ни одной причины, почему я должен префиксить именно интерфейсы и филды (только эти два символа префиксят в дотнете). Так что я выбираю консистентность и не делаю префиксы вообще.
Тут возникает следующая проблема: дотнетчики вечно пишут `IApi`, что бы потом написать `Api: IApi`. В этом смысле, префикс спает их от придумывания ещё одного названия. Которое довольно сложно придумать, если ты для всего подряд хреначишь интерфейс с едиснтвенным наследником. Лечится довольно просто: тебе нужен интерфейс, только когда ты точно знаешь, зачем он тебе нужен. В таком случае у тебя два кейса — или у тебя действительно интерфейс-ентерфейс, или ты обходишь ограничения платформы.
В первом случае проблем с названием не возникает, у тебя есть условно интерфейс `IDataProvider` и реализация `SqlDataProvider`. Во втором случае проблемы. Вот мой кейс с License.Api — начерта мне нужен интерфейс вообще? Штука в том, что я проектирую либу. Она будет использоваться в нескольких проектах, и в них будет n модулей, которые знают, как работать с моей либой. И если они захотят закрепить юнит-тестами факт, что их модули вызывают методы моей либы так, как они это задумали, им понадобится фигачить моки. А тут проблемы с платформой — в C# с моками всё очень плохо — если захочешь делать мок не на интерфейс, а на класс с не виртуальными свойствами и методами — пойдешь в жопу. Поэтому, если я буду поставлять публичную часть либы в виде класса, пользователи моего кода обречены создавать фасады, прокси, или ещё что — оборачивая мой интерфейс в свой. Это и без тестов иногда хорошая идея, но мне не нужно усложнять людям жизнь, если они сами не хотят её себе усложнить. Короче, мне интерфейс нужен, что бы обойти ограничения платформы.
Я разрулился с названием очень просто. Я назвал класс ТОЧНО ТАК ЖЕ, как и интерфейс. Никогда так не делаете? Зря. Вот как это выглядит в коде
internal class Api : Public.Api {
//...
}
Они и должны называться одинаково, это одинаковые по смыслу сущности. Их отличает факт, что один интерфейс, а второй — реализация: факт, известный IDE и компилятору. Я использовал доступ к интерфейсу `Api` через `.Public`, чтобы разрулить коллизию, но не только за этим. Пространство имён — часть имени типа. По-моему, когда эстетика это позволяет, отличная идея это использовать. Сами посмотрите:
public class Sample {
Dto.User user;
Util.Date dateUtil; // переменные этих типов называем как хотим
Storage.Customer customerStorage;
}
К сожалению, в C# нет инструмента, чтобы заставить людей использовать в коде доступ с пространством имён. А вот в F# есть `RequireQulifiedAccess`-атрибут, вешаешь его на что угодно, и эту штуку можно будет забрать только указав последнюю секцию неймспейса, в которой она лежит. Этот атрибут охрененный, потому что помогает писать выразительное и понятное апи.
Вернёмся сюда
public interface Api {
}
Разобрались с префиксами, теперь нам режут глаза фигурные скобки. В дотнете есть только один православный способ форматировать скобки, вот такой:
public interface Api
{
}
Я очень долго шёл к тому, чтобы отринуть эту традицию. Когда первый раз увидел, как фронтендеры расставляют скобки — долго смеялся. Ну что за идиоты, если скобки на одном столбце, то сразу понятно, какая к какой относится.
Я жил и считал так шесть лет, а вчера решил, что красивее, когда первая скобка на одной строке с декларацией. Применил этот стиль к своим проектам и полчаса любовался своим кодом. Вот так.
Дело в том, что я уверен — код должен быть красивым. Приятные глазу, стройные ряды инструкций, отображающие мой замысел на сигналы в процессоре — жутко сложная инженерная фигня. Как самолёты, оружие или электро-гитары, он хорошо выглядит, когда хорошо работает. Я хочу, что бы мой код работал хорошо.
Тут главное не пойти дальше и не начать учиться у фронтендеров всему остальному. В какой-то момент я оказался настолько очарован возможностями системы типов в тайпскрипте, что подсознательно решил — фронты знают что делают. Надо учиться у них. На практике, создатели тайпскрипта — единственные люди во фронтенде, которые знают что делают. Остальные фронты, с которыми я работал, творят какую-то немыслимую дичь. Меня спустил на землю кейс, когда я захотел сделать приложение на электроне. А потом пять синьоров фронтендеров не смогли мне сделать так, что бы я его мог дебажить из IDE или текстового редактора. Они все настраивали мне дебаг ДЕСКТОП-приложения в браузере, и говорили, что так и надо. Сама либа electron в браузере не работает. Т. е. я мог дебажить одну половину приложения (без UI) в IDE, а вторую — в браузере. Их взаимодейтвие — нигде. И мне дали понять, что если меня это не устраивает — пошёл я нахрен. И я пошёл.
Статья для дотнетчиков, так что, парни, давайте передохнём минуту и хорошенько поржём над «НАСТРОИТЬ ДЕБАГ».
Ещё момент. Когда я вижу `}` — мне надо быстро понять, какую конструкцию она закрывает. Поняли? Конструкцию, а не `{`. У меня `}` находится в одном столбце с декларацией интерфейса, который заканчивается на этой скобке. Посмотрел на скобку, поднял взгляд, увидел, что она означает конец интерфейса. Всё.
Мне важно насколько много кода ты увидишь в одном экране. В этом плане я настоящий идеалист — если файл с кодом требует больше одного моего экрана (он у меня довольно мелкий), я делаю всё что могу, что бы его сократить.
Например, мой подход со скобками экономит мне по строке на метод.
Я хочу, что бы было так: открыл файл, окинул взглядом, всё понял. Что делает, как делает, зачем делает. Это недостижимо, но портянки на 500 строк отодвигают тебя от идеала на три миллиарда километров.
Самое главное — понимать, что ты экономишь не свой ресурс печатанья. Ты экономишь место в сорцах, ты стараешься выразить то же самое меньшим количеством кода. Что бы вот именно файл с кодом был как можно лаконичнее, но не терял в читабельности.
Привычка писать небольшие файлы порождает другие. Я ненавижу, когда коллеги говорят: «Не надо плодить лишних абстракций!». Фраза абсолютно верная — лишнее не нужно. Проблема в том, что её часто понимают: «Не плодите абстракции». Так происходит потому, что отличить лишнюю абстракцию от нужной — тяжелый труд. В каком-то смысле, в этом и заключается наша работа. Но мозги не хотят трудиться, мы хотим одно решение, которое работает везде. Поэтому есть разрабы, которые фигачат портянки на тыщу строк, и есть такие, которые выделяют абстракции на каждый чих. Я не хочу выбирать из двух зол. Каждый раз, когда пишешь код, каждый конкретный раз тебе придётся думать заново. Нет и быть не может гайда, который расскажет тебе, как поступать всегда. Есть принцип единой обязанности, который все понимают по-разному. Ну так вот, не так важно, как ты его понимаешь. Что по твоему входит в термин обязанность, является ли делегирование или посредничество дополнительной обязанностью, можно ли считать валидацию входных данных обязанностью, и так далее. Важно вот что — ты всегда должен об этом думать. Подумал, написал код, подумал ещё раз, написал тесты, подумал ещё раз. Закрыл IDE, пошёл спать и ещё раз подумал. А потом переписал нормально. И так до бесконечности.
Так работает не только SRP — весь SOLID. Это не штука, которую надо зазубрить для собеседований, и не гайд, по которому надо все делать. Это такая вещь, которая говорит тебе, о чём ты должен подумать, когда пишешь код.
Код стоит дорого, не потому, что он хорошо выглядит, масштабируется или работает. Код стоит дорого, потому что над ним хорошо подумали. Подумали много часов, много людей, с очень дорогим опытом.
Но вот что я тебе точно могу сказать — если у тебя класс на десять экранов — ты все сделал неправильно. Передумай и переделай.
Моя война с большими файлами продолжается — я строго против xml-документации. Она всегда уродует код, иногда делает понятнее хреновый код, иногда дублирует информацию, представленную хорошим кодом. Братан, у тебя есть имя неймспейса, имя класа, имя метода. Имена параметров. Их типы, и имена этих типов. Если всех этих вещей тебе мало, что бы объяснить, как мне использовать твой код, документация тебе не поможет. Она просто замаскирует твою некомпетентность.
Если ты владеешь тайным знанием о только что написанной кодовой базе — у тебя есть Git. Клади в свои комиты и пуллреквесты столько информации, сколько хочешь. Чем больше, тем лучше. История гита — это лучшее место для хранения мета-инфы о кодовой базе.
Правда, есть исключение. Кейс с исключениями (каламбур не специальный). В C# исключения, которые может создать твой метод, не являются частью его типа — единственный механизм, что бы уведомить пользователя твоего кода, что он должен обработать исключение — это xml doc. Лично я пишу документацию об исключениях на уровне интерфейса — потому что считаю, что это не деталь реализации, а часть контракта.
Да, ещё есть кейс, когда дока публичного апи — часть бизнес-требований, и тут ничего не поделаешь, но пока это не так — к чёрту xml. Пишите C#.
Я борюсь с многострочностью везде, где могу. Я люблю писать так:
if (somethingBad) throw new Exception();
if (somethingSuperBad) throw new SuperException();
if (somethingCriticalBad) throw new CriticalException();
Такой код легко прочитать, его легко понять. В отличие от
if (somethingBad)
{
throw new Exception();
}
if (somethingSuperBad)
{
throw new SuperException();
}
if (somethingCriticalBad)
{
throw new CriticalException();
}
Я не люблю лишние вертикальные вайтспейсы по тем же причинам.
Да, я против префиксов `I`, `_` и т. д., но я за постфикс `Exception` — потому что это не конструкция языка — это просто наследник определенного класса. Я не могу заставить IDE красить их в отдельный цвет. Базовые классы, интерфейсы, свойства-филды-методы-ивенты-структуры — всё это хорошая IDE умеет подсвечивать.
Современные IDE поставляются с CodeLens — вот они мне и заменяют вайтспейсы во многих местах. Я не сторонник идеи разработки под конкретной IDE для всей команды, но простите, я не готов писать код так, что бы над ним удобно было работать в блокноте или смотреть его с телефона. Над кодом работают за компом, в современной IDE. Код ревью делается из IDE, если это настоящее ревью, когда действительно разбираешься, что и как сделано. Да, ты можешь смотреть и оценивать код через UI всяких гитхабов, но то, что они не настолько удобны в обозревании сорцов, как среды разработки — это их проблема, а не моя. Нам совершенно точно нужны инструменты помощнее.
Если ты имеешь дело с очень длинной строкой, не грех и перенести
var result = DomainName
.ClassName
.MethodName(
parameter1,
parameter1,
parameter1,
parameter1,
parameter1
)
не супер красиво, но тут красиво и не получится. Надо сделать максимально не дерьмово.
Кстати, есть новая мода писать ',' в конце последней строки любого перечисления. Я её ненавижу, потому что, после запятой должно что-то идти. И когда я вижу такое:
var result = DomainName
.ClassName
.MethodName(
parameter1,
parameter1,
parameter1,
parameter1,
parameter1,
)
У меня начинает болеть мозг.
Теперь опять к нашему `Api`
Нам нужно добавить сюда метод, который делает запрос по пути `api/license/install`.
Я сделал это так:
public interface Api {
Task<Response.Install> Install(Parameter.Install parameter);
}
Инсталл Инсталл Инсталл? Да.
`Response.Install`,
`Api.Install`,
`Parameter.Install`.
И мне не нужно придумывать никаких странных имен ака `InstallResponse`, который лежит в неймспейсе `Install`.
Возникает вопрос с неймингом `parameter`. Я назвал параметр метода словом `parameter`. Попахивает говном, на деле не говно. Потому что это параметр запроса, т. е. слово `parameter` в этом кейсе — сущность предметной области и моего домена.
Надо сделать класс параметра:
public struct Install {
public Install(string hardwareId, string userId) {
this.HardwareId = hardwareId;
this.UserId = userId;
}
public string HardwareId { get; }
public string UserId { get; }
}
О, да. Это никакой не класс, это структура. Я очень люблю структуры. Сморите. Тут мы описываем сущность, которая представляет из себя набор параметров для запроса. Это чистые данные, у них нет поведения, и мы сейчас не видим кейсов, в которых нам понадобится их модифицировать. Кроме того, если мы где-то в коде захотим сравнить два разных инстанса параметров к одному и тому же запросу, скорее всего нам захочется понять, одинаковые ли у них данные, а не одна ли это ссылка. Например кейс с меморизацией. Если у меня есть запрос, который при одинаковых входных данных гарантированно отдаёт одинаковые результаты — я могу захотеть обрабатывать это на клиенте. Запомнить ответ, и, если запрос будет такой же, не ходить на сервер, а вернуть предыдущий. Со структурами я просто сделаю себе мапу:
Dictionary<Parameter.Install, Response.Install> sessionCache;
private Response.Install Install(Parameter.Install parameter) {
if (this.sessionCache.TryGetValue(parameter, out Response.Install response)) {
return response;
}
// иначе делаем запрос на сервер
}
Если бы у меня были классы, мне пришлось бы у каждого оверрайдить иквалзы и хешкоды, ручками перечисляя все их свойства. Структура это автоматизирует.
Структуру нельзя наследовать. Это важно, потому что я, как разработчик этого кода, не предусматриваю сценария с его наследованием. И рассматриваю это как часть инкапсуляции — с моим кодом можно делать ровно то, что я задумал. Но если тебе нужно наследование, например, что бы избежать дублирования — в жопу структуры. Хотя иногда можно обойтись композицией.
В дотнете не существует лучшего способа показать, что вот эта сущность — чистые данные, а не сервис, объект с поведением или что-то ещё. Я спроектировал структуру `Parameter.Install` так, что бы она была иммутабельной — как внешне, так и в кишках. Это очень важный момент — когда ты не видишь сценариев мутаций, их следует ограничивать. Во-первых, тоже часть инкапсуляции, во-вторых, работа с иммутабельнымы данными в принципе безопасней и надёжней. В-третьих, в-четвертых, исследования, шмиследования, если коротко — оопшники не правы, фпшники правы. Я, как любой нормальный дотнетчик, разрабатывал с использованием F#, и не готов тратить своё время на доказательства очевидного. F# следующая ступень развития C# не потому, что он новый, а потому, что он более функциональный.
И вот в чём это проявляется: мой подход со структурами — говно собачье. Потому что в C# структуры имеют конструктор без параметров по умолчанию. Его невозможно сделать приватным или отключить, вся моя надёжность разбивается об этот факт. Этого достаточно, что бы я отказался от структур и переписал параметр так:
public sealed class Install {
public Install(string hardwareId, string userId) {
this.HardwareId = hardwareId;
this.UserId = userId;
}
public string HardwareId { get; }
public string UserId { get; }
}
Причём в дотнете есть действительно хороший инструмент чтобы описывать данные — рекорды. Но он есть в F#, а в сишарпе его только собираются добавить. Собираются, потому что он есть в F# и прекрасно работает.
Сам класс написан на C# 8.0, .net core 3.1, а это значит, что я могу включить новую фичу:
<Nullable>enable</Nullable>
И я это делаю — потому что это одна из лучших возможностей, что мне подкинула платформа за все то время, что я с ней работаю.
Если коротко, в дотнете древняя и больная проблема с `null`. У тебя все ссылочные переменные могут иметь значение `null`, и компилятор не отличает проверенные от непроверенных. Поэтому в любой кодовой базе, написанной на C#, мы проверяем на `== null` всё и везде. Проверяли. Потому что теперь у нас появилась возможность сказать компилятору — вот эта переменная не может иметь значение `null`. И компилятор будет запрещать присваивать ей `null`. Точнее, он будет кидать варнинги. Для меня это достаточная гарантия. Потому что все проекты делятся на два типа — такие, в которых варнингов нет вообще, и такие, в которых варнингов бесчисленное множество. И на новые никто не обращает внимания. Так вот, для первых, если я включил у себя нулабл и принимаю в конструкторе не нулы, это значит, что они не скормят мне опасные данные. А вот безбожников, которые плюют на варнинги, никакие мои рантайм проверки не спасут, и думать об их удобстве при разработке библиотеки не следует.
Лично я внутри проекта стал помещать такие штуки
string? data
только в модули, которые работают с IO, где они проверяются, превращаются в
string data
и передаются другим модулям уже гарантированно цельными.
Рассуждая так, я избежал необходимости модифицировать код класса таким образом
public sealed class Install {
public Install(string hardwareId, string userId) {
if (hardwareId == null || userId == null) throw new Exception();
this.HardwareId = hardwareId;
this.UserId = userId;
}
public string HardwareId { get; }
public string UserId { get; }
}
У меня ещё актуальна проблема с проверкой строк на `IsEmptyOrWhitespace`, но это уже вопрос бизнес-требований — если такое требование будет, проверку нужно будет добавить.
У меня C# 8, и у меня есть гарантия, что либой будут пользоваться только проекты на C# 8, иначе я посмотрел бы в сторону атрибутов `[NotNull]`.
Когда я пишу код не так, как принято в .net-сообществе, мне все время кажется: «Что ты делаешь, идиотина?! Взрослые дяди со всем уже давно разобрались, ты же не думаешь, что ты умнее их?». Как русский человек, я не привык думать, что моё мнение хоть чего-то стоит. В такие моменты мне кажется, что я наваял себе воздушных замков, всё для себя логически объяснил, но всё равно тупой и полностью не прав.
С другой стороны, я работал с умными людьми, которые раз за разом вносили в кодовую базу одно и то же говно, сами называли это говном, но ничего не меняли. Я и сам постоянно так делаю, мы вообще довольно консервативные существа, когда речь идёт о чем-то, что и так неплохо работает. Ну нафигачили мы сто тыщ проверок на нулл, ну и че?
Вот был же случай с вирусом и туалетной бумагой. Люди набивают по три тележки туалетной бумаги. Не поймите неправильно, я не против идеи запастись всем необходимым, но, чувак, три тележки толчанки? Или ты заодно купил себе вагон продуктов, или ты *клинический* идиот. И тут я начинаю думать — ну не может же быть, чтобы они все были идиотами? И правда не может. Зато может быть так, что они все, в каком-то конкретном случае, начинают вести себя как идиоты. Их так много, и это так заразно, что в какой-то моменты все начнут считать идиотом тебя — потому что ты не забил свой дом туалетной бумагой. И вот в таком случае лучшее, что ты можешь сделать, — это посылать их к черту и не поддаваться массовому безумию. Люди, которые передают потенциальные нулы по всей кодовой базе, ведут себя в сто раз более по-идиотски, чем туалетнобумажники.
У меня уже есть класс `Parameter.Install`. `Response.Install` в целом точно такой же, просто другой набор свойств.
Пора шагать в сторону реализации. Я зафигачил так:
internal sealed class Api : Public.Api {
private readonly Rest rest;
internal Api(Rest rest) { this.rest = rest; }
public Task<Response.Install> Install(Parameter.Install parameter) =>
this.rest.SafePost<Parameter.Install, Dto.Install, Response.Install>(model, Path.Install);
}
Давайте построчно. Класс запечатанный, потому что я не вижу кейса, где его нужно будет наследовать. Разрешать наследовать на всякий случай я не хочу. Конструктор интёрнал, потому что я хочу отдавать его из либы с помощью какого-нибудь `Create.Api(«endpoint»)`. Это нужно, потому что я не хочу строить у себя полноценный DI, тащить для этого либы и т. д., но при этом мне нужно покрыть либу юнит тестами.
А dependency injection мне не нужен, потому что у меня довольно низкий требуемый уровень абстракции — это просто либа, которая вытаскивает данные с конкретного реста.
Для этого я сделал свой фасад над либой для запросов — интерфейс `Rest`. Сама либа `RestSharp`. Взял её, просто потому что работал с ней раньше, мне на самом деле это не очень важно — у меня же есть фасад, если че — поменяем. Имплементатор пришлось назвать `RestSharpFacade`. Некрасивый нейминг, но по факту правильный, если помнить, что это фасад, а RestSharp — имя собственное, пусть и дерьмовое.
У меня есть метод `Install`, который и делает запрос. Ну, идеологически. На практике он делегирует работу моему extension-методу `SafePost`, и это всё, что о нем надо знать.
А вот `SafePost` придётся рассматривать подробно. Мой интерфейс `Rest` и моя реализация фасада лежат в директории `Http`. Там же лежит файлик `Extension.cs`:
internal static class Extension {
public static async Task<Response> SafePost<Parameter, Dto, Response>(
this Rest rest
Parameter data,
string path
) where Dto : Dto<Response>, new() {
try {
var response = await rest.Post<Parameter, Dto>(path, data);
return response.ToModel();
}
catch (System.Exception e) {
throw new ApiException($"The '{path}' resource request failed",e);
}
}
}
Я выбрал именно метод-расширение, потому что обработка невалидных респонсов — не обязанность класса Api, его работа — описывать и вызывать запросы. И это не обязанность класса `RestSharpFacade`, потому что его работа — упрощать для меня API сторонней либы. Он должен делегировать, а не проверять. Но с точки зрения семантики использования — моему классу `Api` не нужна зависимость, которая занимается проверкой ответа — мне вообще не нужно, что бы класс `Api` знал что-то кроме своих запросов. Метод-расширение подошёл идеально.
Классический нейминг extension-method в C# — `ClassNameExtensions`. Мне он не нравится. У меня есть директория `Http`, внутри лежит файл `Extension`, в котором лежат все методы-расширения, работающие с сущностями из этого фолдера. И подключаться эти расширения будут вот так: `using Http`. А раз они так будут подключаться — вообще пофигу, как они называются (мы их название нигде не используем) — это раз, а два — для нас не имеет смысла разделять их по сущностям. Ведь подключаем-то мы сразу все расширения из фолдера — по другому не получится.
Этот метод нужен потому, что кроме `Install` у меня будет ещё десяток запросов, и я не хочу дублировать в них код обработки исключений и конструирования моделей. Метод принимает целых три дженерика — это не круто, но я не смог придумать архитектуру лучше.
Тут проблема в сериализации/десериализации. Либы в дотнете предлагают такой подход:
class MyIncomingDto {
[DeserializeAs(Name = "name_in_json")]
public string? Prop { get; set; }
}
class MyOutgoingDto {
[SerializeAs(Name = "name_in_json")]
public string Prop { get; set; }
}
Скармливаешь тип `MyDto` либе, и она парсит в него json с сервера. Это удобно, что бы получить данные, но таким классом невозможно пользоваться. Во-первых, класс не может иметь конструктора с параметрами, во-вторых, все поля в нём — nullable, в-третьих, в модели, с которой я хочу работать, кол-во полей и их типы обычно отличаются от исходной. Более того — в моём случае на один и тот же запрос могут приходить разные форматы ответов. И получается, что я могу отдавать клиент-класс с кучей опциональных полей, или, если буду использовать dto+response, смогу выражать это закрытой иерархией наследования. Поэтому мне нужен тип DTO, и тип итоговой модели — `Response`. Который уже пацанский, гарантированно наполненный валидными данными, с такой структурой, которая удобна мне, а не json-у.
В итоге запрос описывается тремя типами — тип параметра, тип DTO и тип ответа. Так как дизайн RestSharp заточен так, что модели отвечают за то, как они будут сериализованы и десериализованы, я решил, что я так и сделаю. Я выделил интерфейс `ConvertsTo`, который описывает модели, умеющие превращать себя в респонсы.
Вот так:
internal interface ConvertsTo<out T> {
T Convert();
}
все мои DTO его имплементят, и я использую это вот так:
var dto = await this.rest.Post<Parameter, Dto>(Path.Install, parameter);
// Dto is ConvertsTo<Response.Install>, Response.Install - возвращаемый тип.
return dto.Convert();
Поинт по неймнигу интерфейсов. В дотнете их называют вот по такому паттерну: `ISomethingable`. Про префикс я уже все сказал, постфикс `ble`, по-моему, уместен не всегда. Есть, например, родной интерфейс `IConvertible` — конвертируемый. Название этого интерфейса отвечает на вопросы «какой, что». Мне кажется, это такой костыль, когда суть интерфейса у тебя описывается глаголом, но ты хочешь называть свои сущности существительными. У меня нет сакральной тяги к существительным, поэтому, когда я делаю интерфейс с единственным методом, я называю его глаголом. Получается красиво: `Customer: ConvertsTo` — кастомер, конвертируется в инфу о сущности. У нас есть принцип разделения интерфейсов, и мой нейминг подталкивает к тому, чтобы его соблюдать.
Вернёмся сюда:
public static async Task<Response> SafePost<Parameter, Dto, Response>(
this Rest rest
Parameter data,
string path
)
where Dto : Dto<Response>, new() {
try {
var response = await rest.Post<Parameter, Dto>(path, data);
return response.ToModel();
}
catch (System.Exception e) {
throw new ApiException($"The '{path}' resource request failed",e);
}
}
Метод называется `SafePost`, и тут проблема — какой же это сейф, если собака плюется исключениями? Я решил, что раз уж он ловит любое исключение, а отдает одно ожидаемое, то в контексте своего использования (он у меня приватный, и используется внутри одного запечатанного класса) он может считаться безопасным — внутри этого класса, когда вызываешь этот метод, ты не должен обрабатывать ошибки от него, работаешь с ним как с безопасным. Тут стоит поговорить про исключения вообще. Я против исключений идеологичиски. Мне нравится функциональный подход, когда ты используешь `Result`-монаду — вот такую:
// F#
type Result<T> = value of T | exception of Exception
let sample (data: int Result) =
match data with
| value -> value + 1 // если всё норм, вернем data + 1
| exception ->
log exception
0 // если исключение, логируем и возвращаем 0
Этот подход крут тем, что компилятор заставит тебя обработать потенциальную ошибку. А после обработки ты получишь цельные данные. И их цельность будет доказана на этапе компиляции, ты сможешь передавать остальным модулям что-то такое, что гарантированно не нужно проверять.
К сожалению, в C# так делать не стоит. Во-первых, в шарпах нет DU — типов-объединениий, и тебе придётся костылить свою `Result`-монаду с помощью наследования. Во-вторых, окей, свой код ты сможешь обрабатывать так. Но сторонний-то плюется исключениями — тебе придется его оборачивать в свою монаду повсюду, что породит кучу некрасивого кода. Не говоря о том, что ты же точно не знаешь, какой сторонний код какие исключения выкидывает — у тебя нет гарантий, только xml doc. Который далеко не факт, что есть и правдив. То есть, оборачивать придётся вообще всё. А, кроме того, надо учитывать — вот я тут для себя всё круто напридумывал, но публичное API моего кода один черт надо делать так, что бы оно было более-менее привычно для среднестатистического дотнетчика.
Но это ещё ладно. Код писать — не мешки ворочать, есть проблема посерьезнее — так не делают. Один вопрос, когда ты делаешь свой код стилистически особенным, другой — когда у тебя необычный рантайм. Вообще, когда делаешь что-то не так, как все остальные — ты обречен думать, что ты идиот. Да, я точно уверен, что лучше, когда исключения обрабатываются, чем когда нет. Эта уверенность — ничто перед инстинктом идти вместе со всеми. Я не верю, что хоть в чём-то могу быть умнее всех, кто об этом думал. И никогда не поверю.
Это косвенно подтверждается историей с исключениями у джавистов. Создатели Java — умные люди, они сделали обработку исключений обязательной. Но разрабы во всем мире не хотят думать, что делать, если всё идёт не по плану.
Но я заталкиваю эти мысли поглубже в задницу и иду дальше:
public static ShouldBeHandled<Response> SafePost<Parameter, Dto, Response>(
this Rest rest
Parameter data,
string path
) where Dto : Dto<Response>, new() {
Нейминг женериков. В дотнете их любят именовать T, TSome и т. д. Мне это не нравится. Я допускаю использование имени дженерик-параметра T, когда у тебя T — синоним словосочетания «что угодно». Когда у этого параметра есть хоть какой то смысл, лучше написать его словом. Например `Parameter`. При этом префикс к слову `TParameter` — максимально неуместен. Что ты хочешь им показать? Что речь идёт о типе? Ну, когда сможешь запихать в угловые скобки что-то кроме типов, тогда и приходи со своим префиксом.
Когда я обсуждал такой нейминг женериков с друганом, он сказал мне: «Я всегда так и делаю». Тогда я подумал, что часть этих подходов я стал использовать в дотнете далеко не первый. Это не плохо. Я очень люблю доходить до чего-нибудь, что известно абсолютно всем, сам — потому что возможность изобретать — пусть даже уже изобретенное — это самое крутое в моей работе. Я не знаю алгоритмов квиксорта и пузырьковой сортировки, хотя мне очень интересно, как они работают. Я могу в любой момент прочитать, как они делаются, но я не читаю — вместо этого я постоянно пытаюсь их придумать. Это кайфовый процесс. Где-то в начале обучения программированию я обрёл навык придумывать код без помощи IDE или блокнота, и теперь, когда я остаюсь наедине с собой, мне всегда есть о чём подумать. Прикол в том, что я не проверяю придуманные алгоритмы сортировки — всегда есть шанс, что я уже придумал пузырьковую, или какую-то ещё. Но мне это не важно. Но если у меня будет задача реализовать сортировку, я загуглю, потому что игрушки игрушками, а работу надо делать хорошо.
Вообще, мне каждый день приходиться узнавать, как другие люди решают задачи. Профессия разработчика принуждает тебя принять факт, что ты ничего не знаешь, сообщество знает всё. Ты никогда не работаешь один, и ты никогда не работаешь для себя. Когда я работаю над личным пет-проектом, в моей голове есть вымышленая команда из будущего, которая говорит мне: «Серьёзно? Ты думаешь, с этим говном можно будет работать? Загугли, как это должно выглядеть, и ПЕРЕДЕЛЫВАЙ.». Я переделываю. Программисты — линейная пехота, а не горцы с клейморами.
Но иногда очень хочеться побыть чем-то большим, чем гладкий, отточёный и напрочь предсказуемый винтик с конвейера.
Вы уже знаете, как у меня работает сериализация и десериализация. Осталось прояснить, как устроен `ConvertsTo`.
namespace Dto {
internal sealed class Install : ConvertsTo<Response.Install> {
[DeserializeAs(Name = "user_name")]
public string? UserName { get; set; }
[DeserializeAs(Name = "user_email")]
public string? UserEmail { get; set; }
public Response.Install Convert() {
var userName = Guard.AsValidString(this.UserName, nameof(this.UserName));
var userEmail = Guard.AsValidString(this.UserEmail, nameof(this.UserEmail));
return new Response.Install(userName, userEmail);
}
}
}
`Response.Install` просто содержит два свойства и разрешает их читать. `Dto.Install` умеет быть десериализованым и после этого создавать инстанс респонса. Тут два больших вопроса. Почему Dto отвечает за инстанцирование модели? Попахивает нарушением SRP. И да, это он и есть, если в теле метода `Convert` будет бизнес-логика. Сейчас её нет — есть её делегирование классу `Guard`. То, как сейчас выглядит мой гард, может ввести в заблуждение, потому что он похож на кастомное расширение возможностей языка, но здесь это не так. Условия валидности данных с сервера определяются бизнес-требованиями. Бизнес решает, подходят нам пустые строки, или нет. Эти правила отражены в классе `Guard`. Мой текущий не самый удачный нейминг и сигнатура объясняются просто — у меня ещё нет этих требований. В будущем `AsValidString` заменится на какой-нибудь `AsValidUserName`.
С учетом всего этого, лучше считать Dto и Response одним целым — просто одна часть (Dto) у нас internal, а другая публичная. В моих глазах это оправдывает конструирование модели здесь.
Более важный вопрос — использование статического класса `Guard`. Со статическими классами всё плохо. Я делю дотнетчиков на три типа — начинающие, которые пишут статик повсюду, потому что им так легче. Обычные дотнетчики, которые вот прям всё инжектят и слишком умны для статических классов.
А ещё есть хорошие разработчики, которые понимают — если ты можешь выразить свой сервис чистыми функциями — статический класс для этого идеально подходит. Если у тебя не получается чистых функций, но у тебя получаются функции, которые дают один и тот же результат при одних и тех же параметрах, и этот результат не зависит от контекста и среды выполнения — статический класс твой братан.
internal static class Guard {
/// <exception cref="InconsistentServerModelException" />
public static string AsValidString(string? value, string propertyName) {
if (value == null || string.IsNullOrWhiteSpace(value))
throw new InconsistentServerModelException(propertyName, value);
return value;
}
}
Вот выдержка из моего класса `Guard`. Его работа — проверять значения и швыряться исключениями доменного типа. Из-за исключений функции не чистые, но главное правило соблюдается. Если я захочу покрыть кейсами сценнарий, в котором `Dto.Convert` должен упасть на невалидных данных, мне не понадобится делать мок на `Guard` — гард ведёт себя максимально предсказуемо.
Вообще, если примешь конвенцию размещать свою бизнес-логику в статических классах с чистыми методами, это даст тебе плюсы в читаемости. Если соблюдается эта конвенция, то логику легче покрывать тестами, код быстрее читается, не возникает случайных зависимостей. Ещё плюсы в том, что их можно смело и жёстко композировать. Не нужно плодить одноразовые интерфейсы для них. Благодаря этому навигация по коду получается проще.
Причём, я бы мог сделать свой класс `Guard` екстеншн-методом к классу `string`. Тут есть интересная магия дотнета, так можно делать и не словить `null reference`. Довольно крутой кейс, но конкретно тут я решил обойтись без методов расширений: я хочу спроектировать гард так, что бы он работал со свойствами dto любого типа, и вся эта логика валидации входных данных была в рамках одной сущности.
Я каждый день пишу код на C# — я получаю за это деньги, это единственная область, в которой я могу назвать себя профессионалом. С большой натяжкой. Кроме работы у меня есть пет-проекты — я пишу на F#, Java и TypeScript. Из-за того, что я не считаю себя профессионалом в этих языках, я пишу код на них не как принято, а как считаю нужным. И меня не покидает ощущение, что именно так это и должно работать. Да, я не получаю деньги за свой код на TypeScript, но я неплохо его знаю, и, по-моему, мой код на нём получается вполне приемлемым. В отличие от моего кода на C# — я всю жизнь следовал общепринятым практикам, и мой код был похож на говно. С моей точки зрения. Я считал его говном, и всё равно писал так — потому что сообщество умнее меня. Благодаря другим ЯПам, я понял, что можно и по-другому. На F#, например, стайлгайдов особо нет, но мой код понятен, чист и прост.
Если я прав, и текущие популярные подходы в C# не очень, то почему тогда они популярные? И зачем они вообще нужны? Чтобы код могли написать и прочитать люди, которые, если разрешить им думать самим, напридумывают полную чушь.
Будет очень здорово, если все в мире будут писать одинаково хороший код. Так не получается. Если хочешь, что бы был всеобъемлющий гайд от гугла, который говорит тебе, как делать всё, не забудь заодно попросить у своего бизнеса давать тебе ровно те же задачи, бюджет и условия, которые были у создателей этого гайда.
Поэтому мы пытаемся сделать так, чтобы все писали одинаково одинаковый код. И мы так на этом настаиваем, что даже если мы об этом подумаем и решим, что сделать как у всех — плохо, мы всё равно делаем как у всех. Мы готовы осознанно написать худший код, что бы подогнать его под практики, которые созданы для того, что бы спасти нас от плохого кода.
Вообще пофигу, как ты переносишь скобки. Как ты именуешь интерфейсы, как ты размещаешь свою бизнес логику, и всё такое прочее. Важно одно — когда ты приносишь мне свой код, я должен быть уверен — ты обо всём этом подумал. Я могу подумать за тебя, но тогда ты не нужен. Твой код кто-то воспринимает как труд и работу не потому, что ты напечатал символы, которые компилируются и решают задачу. Смысл в продуманности. Ты не сделал, как у всех, а сделал так, как здесь должно быть сделано. По-твоему должно. Это ведь ты разработчик, да? Тем более, Фил, ты старший разработчик. Тебя никто бы не позвал поработать, если бы им не нужно было, что бы ты думал при разработке!
Ещё как позвали бы. Надо, чтобы как у всех. И пока мы разрешаем людям, которые не хотят думать, работать с нами в одной индустрии — это оправданно. Потому что их так много, что в итоге это они всю работу и делают.
Я написал проект по-своему, начал писать статью. В процессе написания пересмотрел ещё пару подходов. Переделал проект с их использованием. Обосрался со структурами — придумал заюзать их, но я некомпетентный болван, который ЗАБЫЛ про дефолтный конструктор. Выпилил структуры. Дописал статью. А потом переделал проект так, что бы он выглядел как у всех.
Для меня это проектная работа; меня позвали, что бы я заложил архитектуру, скелет, и всё вот это вот. Я сделаю это и уйду. Они наймут людей, которые не смогут работать с моим высером, и всё переделают на как у всех. Но даже это они сделают в разы хуже меня, ведь они тупицы. А тупицы, какой трафарет им не сделай, сколько гайдов не напиши, — всё равно сделают хуже, чем ты ожидал.
Тупицы будут переписывать мой код, а я буду сидеть без работы в разгар кризиса, потому что до меня никак не дойдёт — никому тут не усрались моё видение и вдумчивость. Не потому, что всем нужны тупые разработчики. Потому что я тупой. Я амбициозный, как умный, но я тупой. Я не вышел мозгами, чтобы определять практики для всех людей на земле, и не вышел характером, что бы делать не так, как считаю нужным, а так, как решили другие.
Это не «тупицы» лишние в индустрии. Это я в ней лишний.
На правах рекламы
Эпичные серверы — это виртуальные серверы для размещения сайтов от маленького блога на Wordpress до серьёзных проектов с огромной аудиторией. Представляем множество тарифных планов, где каждый найдёт нужное ему предложение.