Parsing environment variables with reverse tests

3 / 5 on ldap-bot March 23, 2020 &russian @code #haskell #lens #polysemy #QuickCheck

Тема сегодняшней статьи – чтение конфигурационных значений из переменных окружения и связанные с этим процессом трудности. В небольших системах, где нет необходимости в полноценном конфигурационном файле, принято брать настройки из переменных окружения, это один из ключевых моментов 12 factor app манифеста. Это надежный и относительно безопасный способ конфигурации, он отлично поддерживается всеми операционными системами, облачными платформами и средствами контейнеризации.

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

Способ преодоления таких трудностей эволюционно-естественен – необходимо сконцентрировать работу с конфигурацией в одном месте, сделать процесс добавления новой настройки понятным, упростить доступ к настройкам в коде бизнес-логики.

Постановка задачи

В Haskell, несмотря на всю его “строгость” и приверженность к математически чистым функциям, тоже можно обращаться к переменным окружения откуда угодно, но “тут так не принято”… Язык подталкивает программиста отказаться от идеи так делать, заставляя явно отказываться от “чистоты” функций и терять все связанные с этим свойством преимущества. В мире строго-типизированных языков “удобно” не читать “настройки” посреди кода с логикой, а читать их в начале исполнения программы, преобразовать во внутреннюю структуру данных (с адекватными типами вместо строк) и использовать явно передавая такую структуру или ее части в остальные “вычисления” оставляя их свободными от side-effect-ов.

Довольно философствований, show me the code, как говорится.

data Config = Config
  { _ldapHost               :: Text
  , _ldapPort               :: PortNumber
  , _port                   :: Int
  , _verifyToken            :: Text
  , _pageToken              :: Text
  , _user                   :: Text
  , _password               :: Text
  , _activeUsersContainer   :: Dn
  , _projectGroupsContainer :: Dn
  , _projectGroupsOrgunits  :: NonEmpty Text
  }
  deriving (Eq, Show, Generic, Default)

Config – та самая структура данных с настройками, необходимыми для работы Group Manager бота. В начале работы системы, эта структура заполняется значениями из переменных окружения.

readConfig :: (Member Environment r, Member (Error Text) r) => Sem r Config
readConfig =
  Config <$> lookupText "LDABOT_LDAP_HOST"
         <*> lookupNumber "LDABOT_LDAP_PORT"
         <*> lookupNumber "LDABOT_PORT"
         <*> lookupText "LDABOT_VERIFY_TOKEN"
         <*> lookupText "LDABOT_PAGE_TOKEN"
         <*> lookupText "LDABOT_USERNAME"
         <*> lookupText "LDABOT_PASSWORD"
         <*> (Dn <$> lookupText "LDABOT_USERS_CONTAINER")
         <*> (Dn <$> lookupText "LDABOT_GROUPS_CONTAINER")
         <*> (fromList . splitOn "," <$> lookupText "LDABOT_GROUPS_ORGUNITS")

Как ни странно, функция readConfig является “чистой”, хотя вроде бы и обращается к внешнему миру (то есть имеет side-effect-ы). Почему это так и как работает – я расскажу в следующей статье про “алгебраические эффекты”. А пока, еще немного деталей реализации:

lookupText :: (Member Environment r, Member (Error Text) r) => Text -> Sem r Text
lookupText name = lookupEnv name >>= \case
    Nothing     -> throw $ unwords ["Please set", name, "environment variable."]
    Just string -> return $ pack string

lookupNumber :: (Read a, Member Environment r, Member (Error Text) r) => Text -> Sem r a
lookupNumber name = read . unpack <$> lookupText name

Функция lookupText обращается к операционной системе через lookupEnv name и анализирует результат. Если значения не оказалось – генерируется ошибка, в противном случае – функция возвращает значение переменной окружения. lookupNumber является надстройкой над lookupText, которая после успешного получения значения конвертирует его в число. Интересным моментом тут является оператор <$> (так же известный как fmap в Haskell или Optional.map в Java). Его использование позволяет “не засорять” код обработкой граничных случаев вида “если lookupText вернул null, то тоже вернуть null; в противном случае – преобразовать в число и вернуть”. Если вы вспомнили про elvis-оператор, то знайте, он является лишь частным случаем fmap для null-ов ;)

<$> несколько раз применяется еще и внутри readConfig для тех же целей – преобразовывать прочитанное из LDABOT_USERS_CONTAINER в Dn (термин из мира LDAP, означает distinguished name) есть смысл только если там что-то было. Самое первое использование <$> немного интереснее. Помните рассказ про <$> из первой статьи про парсинг json-а? Речь шла о том, чтобы “адаптировать” конструктор структуры данных Message (который принимает строки) к “парсеру строк”. Если посмотреть на такую адаптацию с другой стороны – операция <$> превращала “парсер строк” в “парсер Message-ей” постулируя “когда (и если) оригинальный парсер строк что-нибудь вернет, примени к этому конструктор Message”.

С Config-ом ситуация та же, оператор <$> постулирует “когда (и если) все операнды для вызова функции Config будут готовы – вызывай”. Если ранее мы конструировали Message “в контексте” парсера, который может ничего “не напарсить”, то сейчас мы конструируем Config “в контексте” вычисления, которое может вернуть ошибку. fmap – он как обычный map, только не для списков, а для любых “контейнеров” или “вычислений” (деревья, Optional, парсер, генератор). Подготовка операндов происходит при помощи <*>. Его отличие от <$> в том, что теперь с обоих сторон “вычисления, которые могут вернуть ошибку”. Механика сложная, зато код элегантный, без постоянных проверок (привет программистам на golang) и early return-ов.

Тестирование

С проблематикой вроде разобрались, пора начинать извлекать пользу. из “централизации” работы с настройками а так же от использования “чистых” функций (не зря же прилагались усилия). С точки зрения кода, читающего значения переменных – совершенно не важно откуда именно происходит чтение – из реальных переменных окружения или из заранее подготовленного ассоциативного массива, главное, чтобы lookupEnv возвращала Maybe Text. Определив “тестовое окружение” как простой писок ключ-значение type EnvironmentMock = [(Text, Text)], можно заставить readConfig читать данные из заранее подготовленного места.

withMockedEnvironment :: EnvironmentMock -> Sem '[Environment, Error Text] a -> Either Text a
withMockedEnvironment mockedEnv = run . runError . fakeEnvironment mockedEnv

fakeEnvironment :: Member (Error Text) r => EnvironmentMock -> InterpreterFor Environment r
fakeEnvironment mockedEnv = interpret $ \case
  LookupEnv name -> return $ unpack <$> lookup name mockedEnv

withMockedEnvironment
  [ ("LDABOT_LDAP_HOST", "host")
  , ("LDABOT_LDAP_PORT", "123")
  , ("LDABOT_PORT", "234")
  , ("LDABOT_VERIFY_TOKEN", "vtoken")
  , ("LDABOT_PAGE_TOKEN", "ptoken")
  , ("LDABOT_USERNAME", "user")
  , ("LDABOT_PASSWORD", "pass")
  , ("LDABOT_USERS_CONTAINER", "ucont")
  , ("LDABOT_GROUPS_CONTAINER", "gcont")
  , ("LDABOT_GROUPS_ORGUNITS", "ou1,ou2")
  ] readConfig `shouldBe` Right Config {
    _ldapHost = "host",
    _ldapPort = 123,
    _port = 234,
    _verifyToken = "vtoken",
    _pageToken = "ptoken",
    _user = "user",
    _password = "pass",
    _activeUsersContainer = Dn "ucont",
    _projectGroupsContainer = Dn "gcont",
    _projectGroupsOrgunits = "ou1" :| ["ou2"]
  }

Как говорит один мой знакомый, “мало вариативности”. Он ярый поклонник разработки через тесты, (привет тебе, В.С.)“. Ну что-ж, постараемся добавить вариативности и уважить скептиков, заявляющих при чтении таких тестов –”а как убедиться в том, что реализация не состоит из хардкода именно этих значений”.

Есть такой прием в тестировании – проверять обратимость (reverse(reverse(list)) === list). Построение конфига из окружения - назовем прямым преобразованием Окружение -> Конфиг. Если бы у нас было обратное преобразование (из Конфига в Окружение, из которого такой Конфиг прочитан), то мы бы могли проверить, что применив сначала прямое преобрзование, а затем обратное – получается исходный Конфиг. Такую пару Окружения и Конфига называют изоморфной, а само преобразование – изоморфизмом. Как обычно бывает в математике – слово сложное, но за ним стоит простая идея ;)

Если сначала конфиг (абсолютно любой) преобразовать в набор пар ключ-значение, а потом из них попытаться “прочитать” конфиг обратно, то в итоге должны ведь получить исходный конфиг.

toEnvironmentMock :: Config -> EnvironmentMock
toEnvironmentMock Config {..} =
  [ ("LDABOT_LDAP_HOST", unpack _ldapHost)
  , ("LDABOT_LDAP_PORT", show _ldapPort)
  , ("LDABOT_PORT", show _port)
  , ("LDABOT_VERIFY_TOKEN", unpack _verifyToken)
  , ("LDABOT_PAGE_TOKEN", unpack _pageToken)
  , ("LDABOT_USERNAME", unpack _user)
  , ("LDABOT_PASSWORD", unpack _password)
  , ("LDABOT_USERS_CONTAINER", fromDn _activeUsersContainer)
  , ("LDABOT_GROUPS_CONTAINER", fromDn _projectGroupsContainer)
  , ("LDABOT_GROUPS_ORGUNITS", unpack $ intercalate "," $ toList _projectGroupsOrgunits)]
  where
    fromDn (Dn dn) = unpack dn

Имея прямое и обратное преобразование, можно записать:

it "reads config from complete environment" $ forAll $ \config ->
  withMockedEnvironment (toEnvironmentMock config) readConfig === Right config

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

it "fails to read a config from incomplete environment" $ forAll $ \config -> do
  shuffled <- shuffle $ toEnvironmentMock config
  let ((missingKey, _), incompleteMock) = (head shuffled, tail shuffled)
  return $ withMockedEnvironment incompleteMock readConfig === Left (unwords ["Please set", missingKey, "environment variable."])

Ну вот, кажется удалось свести задачу тестирования функции чтения конфигурации к формированию произвольных конфигов. Эта задача для Haskell довольно типична – использовать property-based тестирование на нем очень любят. Так как структура Config состоит из достаточно примитивных типов и оберток над ними, то “произвольность” можно обеспечить с помощью всего нескольких строк.

makeArbitrary ''Config

instance Arbitrary Config where
  arbitrary = arbitraryConfig
  shrink = recursivelyShrink

Благодаря тому, что Config теперь “реализует” Arbitrary, можно создавать “генератор” конфигов – Gen Config при помощи функции arbitrary из класса.

class Arbitrary a where
  arbitrary :: Gen a

Попробуем в REPL-е сгенерировать что-нибудь случайное:

sample (arbitrary :: Gen Config)

Config
  { _ldapHost = "\nIUZ\DELu\EMUG1\DEL\1002298\11790\DC3s\STX"
  , _ldapPort = 20
  , _port = -17
  , _verifyToken = "\SO\DLE9_1\NUL\210889\681130l\ENQ"
  , _pageToken = "q\r;h1\959827\&1~\703396P1~\837562\190001xjf"
  , _user = "\466790\&6\DC4j"
  , _password = "{H"
  , _activeUsersContainer = Dn ""
  , _projectGroupsContainer = Dn "U\ACK\616135\570186v\672268\571313"
  , _projectGroupsOrgunits = "\615852L$\598568\ESC6\fc" :| ["[h\DC4N[3pzk\b\SUB6\133277\14775"]
  }

Работает! Теперь полиморфная функция forAll, обладающая типом forAll :: (Show a, Testable prop) => Gen a -> (a -> prop) -> Property может принимать на вход “генератор конфигов” и проверять Property (по сути, чуть-чуть более хитрый предикат, где вместо == используется ===).

Env
  environment reading
    reads config from complete environment
      +++ OK, passed 100 tests.
    fails to read a config from incomplete environment
      +++ OK, passed 100 tests.

Строка +++ OK, passed 100 tests. говорит о том, что было сгенерировано 100 случайных Config-ов для проверки инварианта – конвертации “окружение” и обратно. Количество тестов всегда можно задать аргументом командной строки при запусте тестов.

$ stack test --test-arguments --qc-max-success=10000

Env
  environment reading
    reads config from complete environment
      +++ OK, passed 10000 tests.
    fails to read a config from incomplete environment
      +++ OK, passed 10000 tests.

Finished in 2.2859 seconds
2 examples, 0 failures

От каких “ошибок” защищают такие defensive (regression, golden) тесты? Например, если случайно переставить местами строки при построении конфига – тесты это отловят. Либо если попытаться захардкодить какое-нибудь одно значение на этапе построения конфига – тесты тоже просигнализируют с несовпадении значений (сгенерированное случайное значение будет отличаться от статического хардкода). Изменение названия переменных, из которых читаем конфиг, такой тест тоже “отловит”, но отловит тут в кавычках, потому что такое падение теста не говорит о некорректности или неработоспособности программы, оно говорит лишь о том, что тесты нужно обновить, по сути “зашив” в процедуру генерации фейкового оружения новые названия переменных. В.С. непременно бы заметил еще на этапе написания тестов, что названия переменных повторяются и в реализации и в тестах – “не DRY”, сказал бы он в code review комментарии…

Суши с лупой

Для того, чтобы избавиться из повторений, будем использовать популярную в функциональном программировании вещь – линзы. Линза, если совсем просто ее представить, это такая сущность, которая совмещает в себе getter и setter. Ну как setter… программирование же функциональное, immutability везде, нет никаких setter-ов, есть только функции Value -> Object -> Object, которые не меняют Object, а возвращают новый.

В структуре данных Config не случайно свойства начинались с символа подчеркивания, этому есть причина: для каждого поля структуры, Haskell объявит одноименную функцию с сигнатурой, например _ldapHost :: Config -> Text. Если бы поле называлось ldapHost, то часто бы возникал конфликт имен при объявлении временных “переменных”. Да и смотря на использование ldapHost в коде подсознательно думаешь о нем, как о значении, а не как о функции.

Эту конвенцию “эксплуатирует” библиотека lens, позволяющая одной строкой сгенерировать линзы для каждого из полей структуры.

makeLenses ''Config

ldapHost :: Lens' Config Text
ldapPort :: Lens' Config PortNumber
...

Для чего вообще эти линзы удобны? Для работы со вложенным структурами данных в функциональном стиле. Имея список составных объектов.

data Color = Color {_shade :: Text}
data Material = Material {_kind :: Text, _color :: Color}
data Player = Player {_name :: Text, _material :: Material}

makeLenses ''Color
makeLenses ''Material
makeLenses ''Player

let players = [Player "Bender" (Material "metal" (Color "shiny"))
              ,Player "Fry" (Material "meat" (Color "yellow"))
              ,Player "Leela" (Material "meat" (Color "purple"))]

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

view material.color.shade $ head players
"shiny"

map (view $ material.color.shade) players
["shiny","yellow","purple"]

map (over (material.color.shade) (append "super_")) players
[Player {_name = "Bender", _material = Material {_kind = "metal", _color = Color {_shade = "super_shiny"}}}
,Player {_name = "Fry", _material = Material {_kind = "meat", _color = Color {_shade = "super_yellow"}}}
,Player {_name = "Leela", _material = Material {_kind = "meat", _color = Color {_shade = "super_purple"}}}]

Последний пример особенно нагляден, если бы не линзы, пришлось бы писать что-то вроде:

map (\player ->
    let material = _material player
        color = _color material
        shade = _shade color
    in player { _material = material { _color = color { _shade = append "super_" shade } } }
  ) players

Вернемся к нашей задачу из избавлению от дублирования. Объявим список пар ключ-линза – никто не запрещает так сделать, ведь линза, по сути, всего-лишь сложная функция, а функции в Haskell first-class значения:

settings = [
  ("LDABOT_LDAP_HOST",        ldapHost),
  ("LDABOT_LDAP_PORT",        ldapPort . isoRead . packed),
  ("LDABOT_PORT",             port . isoRead . packed),
  ("LDABOT_VERIFY_TOKEN",     verifyToken),
  ("LDABOT_PAGE_TOKEN",       pageToken),
  ("LDABOT_USERNAME",         user),
  ("LDABOT_PASSWORD",         password),
  ("LDABOT_USERS_CONTAINER",  activeUsersContainer . isoDn),
  ("LDABOT_GROUPS_CONTAINER", projectGroupsContainer . isoDn),
  ("LDABOT_GROUPS_ORGUNITS",  projectGroupsOrgunits . isoNonEmpty . splitted)]
  where
    isoRead :: (Read a, Show a) => Iso' a String
    isoRead     = iso show read
    isoDn       = iso (\(Dn dn) -> dn) Dn
    isoNonEmpty = iso toList fromList
    splitted    = iso (intercalate ",") (splitOn ",")

Обратите внимание на уже знакомые нам изоморфизмы снизу – пары функций, которые необходимы для преобразования линз к одному виду Lens' Config Text. Ведь исходя из типа Config линза activeUsersContainer работает с типом Dn, а мы хотим унифицировать все лизны в settings приведя их к одной, строковой сигнатуре.

Процедуру “чтения конфигурации” поменяем на свертку

readConfig :: (Member Environment r, Member (Error Text) r) => Sem r Config
readConfig = foldM reducer (Config {}) settings
  where
    reducer config (name, lens) = do
      value <- lookup name
      return $ set lens value config

Код осуществляет свертку foldM при помощи функции reducer списка settings, используя в качестве начального значения пустой Config {}. Функция reducer имеет на входе два параметра – config в качестве аккумулятор-а и пара ключ-линза из списка settings. Она читает (lookup name) значение переменной окружения, устанавливает прочитанное значение при помощи линзы в config и возвращает его. Таким образом, последовательно пройдясь по всему списку settings все поля структуры Config окажутся заполнены значениями.

Наконец-то мы можем избавиться от дублирования названий переменных в тестах. Вместо свертки, делаем простой обход списка map просматривая через линзу значения в config-е.

toEnvironmentMock :: Config -> EnvironmentMock
toEnvironmentMock config = map (\(name, lens) -> (name, view lens config)) settings

Использование инверсии, идемпотентности и других инвариантов - здорово помогает при написании тестов, Вариативность, как говорит мой знакомый - при этом “на высоте” ;)