Система типов — лучший друг программиста

в 5:07, , рубрики: domain-driven design, архитектура по, информационная безопасность, Программирование, проектирование систем, системы типов, Совершенный код
Система типов — лучший друг программиста - 1

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

Значение в string не лучший тип для записи адреса электронной почты или страны проживания пользователя. Эти значения заслуживают гораздо более богатых и специализированных типов. Мне нужно, чтобы существовал тип данных EmailAddress, который не может быть null. Мне нужна единая точка входа для создания нового объекта этого типа. Он должен валидироваться и нормализироваться перед возвратом нового значения. Мне нужно, чтобы этот тип данных имел полезные методы наподобие .Domain() или .NonAliasValue(), которые бы возвращали для введённого foo+bar@gmail.com значения gmail.com и foo@gmail.com. Эта полезная функциональность должна быть встроена в эти типы. Это обеспечивает безопасность, помогает предотвращать баги и существенно повышает удобство поддержки.

Тщательно спроектированные типы с полезной функциональностью мотивируют программиста поступать правильно.

Например, EmailAddress может иметь два метода проверки равенства:

  • Equals возвращал бы true, если два адреса электронной почты (нормализованных) идентичны.
  • EqualsInPrinciple также возвращал бы true для foo@gmail.com и foo+bar@gmail.com.

Такие специфические для типов методы были бы чрезвычайно полезны во множестве случаев. Пользователю нельзя отказывать в логине, если он зарегистрировался с почтой jane@gmail.com, а пытается войти с Jane@gmail.com. К тому же будет очень удобно сопоставлять пользователя, связавшегося со службой поддержки с основного адреса почты (foo@gmail.com), и зарегистрированный аккаунт (foo+svc@gmail.com). Это типичные требования, которые простой string не может выполнить без кучи дополнительной логики, разбросанной по кодовой базе.

Примечание: согласно официальному RFC, часть адреса электронной почты до символа @ может быть чувствительной к регистру, однако все популярные хосты работают с ней как с нечувствительной к регистру, поэтому логично будет учитывать это знание в типе domain.

Хорошие типы помогают предотвращать баги

В идеале мне бы хотелось пойти ещё дальше. Адрес почты может быть верифицированным и неверифицированным. Обычно адрес электронной почты валидируется отправкой во входящие уникального кода. Такие «деловые» взаимодействия тоже можно выразить через систему типов. Пусть у нас будет второй тип с именем VerifiedEmailAddress. Если хотите, он даже может наследовать от EmailAddress. Меня это не волнует, но убедитесь, что получить новый экземпляр VerifiedEmailAddress можно только в одном месте кода, а именно у сервиса, отвечающего за валидацию адреса пользователя. И внезапно оказывается, что остальная часть приложения может использовать этот новый тип для предотвращения багов.

Любая функция отправки электронных писем может положиться на безопасность VerifiedEmailAddress. Представьте, что было бы, если бы адрес электронной почты был записан как простой string. Разработчику приходилось бы сначала находить/загружать соответствующий аккаунт пользователя, искать какой-нибудь непонятный флаг наподобие HasVerifiedEmail или IsActive (который, кстати, является наихудшим флагом, поскольку со временем его значимость становится всё более существенной), после чего надеяться, что флаг установлен правильно, а не инициализирован ошибочно как true в каком-то стандартном конструкторе. В такой системе слишком много возможностей для ошибки! Использование примитивного string для объекта, который легко выразить в виде собственного типа — это ленивое и лишённое воображения программирование.

Расширенные типы защищают от будущих ошибок

Ещё один замечательный пример — деньги! Просто куча приложений выражает денежные значения при помощи типа decimal. Почему? У этого типа так много проблем, что такое решение мне кажется непостижимым. Где обозначение вида валюты? В любой сфере, где работают с деньгами людей, должен быть специализированный тип Money. Он как минимум должен включать в себя вид валюты и перегрузки операторов (или другие меры защиты), чтобы предотвратить глупые ошибки наподобие умножения $100 на £20. Кроме того, не у всех валют хранится только два знака после запятой. У некоторых валют, например у бахрейнского или кувейтского динара, их три. Если вы имеете дело с инвестициями или кредитами в Чили, то фиксировать условную расчётную единицу нужно с четырьмя знаками после запятой. Этих аспектов уже достаточно для того, чтобы оправдать создание специального типа Money, но и это ещё не всё.

Если ваша компания не создаёт всё самостоятельно, то вам рано или поздно придётся работать со сторонними системами. Например, большинство платёжных шлюзов передаёт запросы и ответы по деньгам в виде значений integer. Значения integer не страдают от проблем с округлением, свойственных типам float и double, поэтому они предпочтительнее, чем числа с плавающей запятой. Единственная тонкость заключается в том, что значения нужно передавать в производных единицах (например, в центах, пенсах, дирхамах, грошах, копейках и так далее), то есть если ваша программа работает с значениями в decimal, вам придётся постоянно преобразовывать их туда и обратно при общении с внешним API. Как говорилось ранее, не во всех валютах используется два знака после запятой, поэтому простым умножением/делением на 100 каждый раз не обойтись. Всё очень быстро может сильно усложниться. Ситуацию можно было бы существенно упростить, если бы эти правила были инкапсулированы в краткий единый тип:

  • var x = Money.FromMinorUnit(100, "GBP"): £1
  • var y = Money.FromUnit(100.50, "GBP"): £1.50
  • Console.WriteLine(x.AsUnit()): 1.5
  • Console.WriteLine(x.AsMinorUnit()): 150

Усугубляет ситуацию то, что во многих странах форматы обозначения денег тоже отличаются. В Великобритании десять тысяч фунтов пятьдесят пенсов можно записать как 10,000.50, однако в Германии десять тысяч евро и пятьдесят центов будут записываться как 10.000,50. Представьте объём кода, связанного с деньгами и валютами, который будет разбросан (а возможно и дублирован с небольшими расхождениями) по кодовой базе, если эти правила не будут помещены в один тип Money.

Кроме того, в специализированный тип Money можно включить множество других фич, сильно упрощающих работу с денежными значениями:

var gbp = Currency.Parse("GBP");
var loc = Locale.Parse("Europe/London");

var money = Money.FromMinorUnit(1000050, gbp);

money.Format(loc)        // ==> £10,000.50
money.FormatVerbose(loc) // ==> GBP 10,000.50
money.FormatShort(loc)   // ==> £10k

Разумеется, для моделирования такого типа Money придётся приложить усилия, но после его реализации и тестирования остальная часть кодовой базы будет в гораздо большей безопасности. К тому же это предотвратит большинство багов, которые в противном случае со временем будут проникать в код. Хотя такие мелкие аспекты, как защищённая инициализация объекта Money через Money.FromUnit(decimal v, Currency c) или Money.FromMinorUnit(int v, Currency c), могут показаться незначительными, они заставляют последовательных разработчиков задумываться, каким из них является значение, полученное от пользователя или внешнего API, а значит, предотвращать баги с самого начала.

Продуманные типы могут предотвращать нежелательные побочные эффекты

Расширенные типы хороши тем, что им можно придавать любую форму. Если моя статья пока не пробудила ваше воображение, позвольте продемонстрировать ещё один хороший пример того, как специализированный тип может спасти команду разработчиков от излишней траты ресурсов и даже багов, угрожающих безопасности.

В каждой кодовой базе, с которой я работал, в виде параметра функции было что-то наподобие string secretKey или string password. Что может пойти не так с этими переменными?

Представьте такой код:

try
{
    var userLogin = new UserLogin
    {
        Username = username
        Password = password
    }

    var success = _loginService.TryAuthenticate(userLogin);

    if (success)
        RedirectToHomeScreen(userLogin);

    ReturnUnauthorized();
}
catch (Exception ex)
{
    Logger.LogError(ex, "User login failed for {login}", userLogin);
}

Здесь возникает следующая проблема: если в процессе аутентификации срабатывает исключение, то это приложение запишет в логи (случайно) пароль пользователя в текстовом виде. Разумеется, подобный код вообще не должен существовать, и можно надеяться, что вы отловите его во время код-ревью до попадания в продакшен, но в реальности такое время от времени случается. Вероятность большинства таких багов со временем растёт.

Изначально класс UserLogin мог иметь другой набор свойств и при первоначальном код-ревью этот фрагмент кода, вероятно, был хорошим. Годами позже кто-то мог изменить класс UserLogin так, что в нём появился пароль в текстовом виде. Затем эта функция даже не появилась в diff, который был отправлен для ещё одного ревью, и вуаля, вы только что добавили в код баг, угрожающий безопасности. Я уверен, что каждый разработчик, имеющий несколько лет опыта, рано или поздно сталкивался с подобными проблемами.

Однако этого бага можно было бы легко избежать добавлением специализированного типа.

В языке C# (который я возьму для примера) при записи объекта в лог (или куда-то ещё) автоматически вызывается метод .ToString(). Зная это, можно спроектировать такой тип Password:

public readonly record struct Password()
{
    // Здесь будет реализация

    public override string ToString()
    {
        return "****";
    }

    public string Cleartext()
    {
        return _cleartext;
    }
}

Это мелкое изменение, но теперь случайно вывести куда-то пароль в текстовом виде оказывается невозможно. Разве это не здорово?

Разумеется, в процессе аутентификации вам всё равно может понадобиться значение в виде простого текста, однако доступ к нему можно получить при помощи метода с очень понятным именем Cleartext(). Уязвимость этой операции становится сразу очевидной, и это автоматически мотивирует разработчика использовать этот метод по назначению и обращаться с ним аккуратно.

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

Такие мелкие хитрости могут оказать большую пользу!

Превратите это в привычку

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

Взяв за основу пример с типом Password, можно снова расширить его!

Перед сохранением в базу данных пароли хэшируются, так ведь? Разумеется, однако хэш — это не просто string. В процессе логина нам придётся сравнивать ранее сохранённый хэш с новым вычисленным хэшем. Проблема в том, что не каждый разработчик является специалистом по безопасности, а потому он может и не знать, что сравнение двух строк хэшей может сделать код уязвимым к атакам по времени.

Рекомендуется проверять равенство хэшей двух паролей неоптимизированным образом:

// Сравнение двух байтовых массивов на равенство.
// Метод специально написан так, чтобы цикл не оптимизировался.
[MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)]
private static bool ByteArraysEqual(byte[] a, byte[] b)
{
    if (a == null && b == null)
    {
        return true;
    }
    if (a == null || b == null || a.Length != b.Length)
    {
        return false;
    }
    var areSame = true;
    for (var i = 0; i < a.Length; i++)
    {
        areSame &= (a[i] == b[i]);
    }
    return areSame;
}

Примечание: пример кода взят из репозитория ASP.NET Core.

Поэтому будет совершенно логично закодировать эту функциональность в специализированный тип:

public readonly record struct PasswordHash
{
    // Здесь будет реализация

    public override bool Equals(PasswordHash other)
    {
        return ByteArraysEqual(this.Bytes(), other.Bytes());
    }
}

Если PasswordHasher возвращает значения только типа PasswordHash, тогда даже не разбирающиеся в безопасности разработчики будут вынуждены использовать безопасный способ проверки на равенство.

Тщательно продумывайте способ моделирования функциональной области!

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

Во многих современных языках программирования есть очень богатые системы типов, и я считаю, что мы сильно недооцениваем их как способ совершенствования своего кода.

Автор:
PatientZero

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js