Что-то, о чем я недавно думал, работая с более крупной системой Haskell, как элегантно распределять значения между процессами?
Я все больше и больше узнаю об отношениях между вводом и выводом функции, l aws, например, инъективность, сюръективность, неинъективные функции и так далее ... я понял, что большинство функций в реальном приложении обрабатывают данные из бизнес-логики c точки зрения уменьшают значения до все меньших и меньших значений, пока вы не окажетесь в каком-то примитивном типе (обычно Bool
), конечно, для бизнес-логики условного стиля c. Или, возможно, какое-то другое значение в поле в вашей БД, которое вы хотите обновить.
Итак, давайте посмотрим на пример того, что я могу иметь в виду. Допустим, у нас есть система со таблицей пользователей, таблицей продуктов и, возможно, таблицей предложений. Пользователь может корзину продукта, и продукт может иметь или не иметь предложения, которое вы могли бы применить к нему. Здесь я проиллюстрирую чистую функцию, которая определяет, что предложение применимо к продукту, и другую, которая вычисляет скидку для этого продукта:
-- lets also assume these types are Persistent data types
data User = User
{ userName :: Text
, userAge :: Int
, userHasDiscounts :: Bool
, userAccountDisabled :: Bool
}
data Product = Product
{ productName :: Text
, productCost :: Money
}
data Offer = Offer
{ offerName :: Text
, offerDiscount :: Discount
, offerProduct :: ProductId
}
productDiscountable :: Entity User -> Entity Product -> Entity Offer -> Bool
productDiscountable (Entity _ user) (Entity productKey _) (Entity _ offer) =
not (userAccountDisabled user) && userHasDiscounts user && productKey == offerProduct offer
productWithDiscountApplied :: Entity User -> Entity Product -> Entity Offer -> Product
productWithDiscountApplied user (Entity productKey product) offer = product { productCost = discountedCost }
where
discountedCost =
if not (productDiscountable user (Entity productKey product) offer)
then productCost product
else applyDiscount (offerDiscount offer) (productCost product)
applyDiscount :: Discount -> Money -> Money
applyDiscount = undefined
Это может быть не очень удачный пример, и мой вопрос больше о том, как все масштабируется в более широкой системе ... но обратите внимание, что в примере для некоторых функций мы отбрасываем данные, которые мы получаем в качестве входных, в productDiscountable
мы отбрасываем Product
и просто берем ключ объекта, а ключ сущности для User
и Offer
только для использования внутренних значений. То же самое и в productWithDiscountApplied
, где мы берем некоторые значения, которые нам не нужны. Что, если бы мы хотели рассчитать скидку с предложением, которое еще не было сохранено в нашей базе данных, например?
Похоже, что типы имели бы смысл быть такими маленькими, как они должны быть:
productDiscountable :: User -> Key Product -> Offer -> Bool
productDiscountable user productKey offer =
not (userAccountDisabled user) && userHasDiscounts user && productKey == offerProduct offer
productWithDiscountApplied :: User -> Entity Product -> Offer -> Product
productWithDiscountApplied user (Entity productKey product) offer = product { productCost = discountedCost }
where
discountedCost =
if not (productDiscountable user productKey offer)
then productCost product
else applyDiscount (offerDiscount offer) (productCost product)
Мне это кажется намного чище, и я обычно пишу Haskell именно так. Проблема, с которой я столкнулся, заключается в том, что в реальной системе, где все эти элементы должны соответствовать друг другу, разные части системы имеют разные требования к данным, а некоторые части могут потребовать чтения из множества разных таблиц. В системе, над которой я работаю, у нас есть функции, подобные приведенным выше, только в некоторых случаях с 5/6 различными объектами. В этих случаях я иногда определяю более крупный тип, чтобы он содержал все разные части. Что-то вроде:
data SomeProcessType = SomeProcessType
{ sptUser :: Entity User
, sptProduct :: Entity Product
, sptOffer :: Entity Offer
-- possibly more types contained within this type
}
Но, как и рассуждения о сужении типов с помощью типа Entity
, есть причина, по которой мы также можем сузить этот тип, допустим, мы хотим объединить аргументы для productDiscountable
в такой тип:
data ProductDiscountableParams = ProductDiscountableParams
{ productDiscountableParamsUser :: User
, productDiscountableParamsProduct :: Key Product
, productDiscountableParamsOffer :: Offer
}
И вы можете сопоставить больший тип с этим типом с помощью некоторой функции:
productDiscountable $ ProductDiscountableParams
{ productDiscountableParamsUser = entityVal (sptUser spt)
, productDiscountableParamsProduct = entityKey (sptProduct spt)
, productDiscountableParamsOffer = entityVal (sptOffer spt)
}
Но тогда это приводит меня к Ход мысли, что ProductDiscountableParams
должен содержать только те значения, которые ему действительно нужны для вычисления результата, подобно тому, как исходной функции не нужны были ключи из Entity
, есть также некоторые поля из типов, которые также не требуются. У нас нет доступа к userAge
от пользователя, так почему мы его передаем? Что, если бы ProductDiscountableParams
был определен только с теми полями, которые ему нужны:
data ProductDiscountableParams = ProductDiscountableParams
{ productDiscountableParamsUserDisabled :: Bool
, productDiscountableParamsUserHasDiscounts :: Bool
, productDiscountableParamsOfferProductKey :: Key Product
, productDiscountableParamsProductKey :: Key Product
}
productDiscountable $ ProductDiscountableParams
{ productDiscountableParamsUserDisabled = userDisabled (entityVal (sptUser spt))
, productDiscountableParamsUserHasDiscounts = userHasDiscounts (entityVal (sptUser spt))
, productDiscountableParamsOfferProductKey = offerProduct (entityVal (sptOffer spt))
, productDiscountableParamsProductKey = entityKey (sptProduct spt)
}
Это кажется хорошим с точки зрения функции, поскольку она принимает только те поля, которые ей требуются. Вероятно, проще написать тест, поскольку ему нужны только некоторые базовые c значения. Но это не кажется таким безопасным (две пары полей с одинаковыми типами), и на него не особенно приятно смотреть с точки зрения вызывающего сайта.
Я начал использовать этот подход в некоторых частях своего
Проблема, связанная с наличием таких типов, как SomeProcessType
, охватывающих несколько бизнес-процессов, говорит, что SomeOtherProcessType
также существует, с похожим, но не идентичным контекстом, эти два типа нуждаются в способ добраться до ProductDiscountableParams
, чтобы вызвать это, если им обоим нужно вызвать эту функцию по какой-либо причине.
Другой подход, который я видел в Интернете, - это люди, использующие классы типов, немного похожие на интерфейс в ОО язык. Вместо этого вы можете определить ProductDiscountableParams
как класс типов:
class ProductDiscountableParams a where
productDiscountableParamsUserDisabled :: a -> Bool
productDiscountableParamsUserHasDiscounts :: a -> Bool
productDiscountableParamsOfferProductKey :: a -> Key Product
productDiscountableParamsProductKey :: a -> Key Product
Вы можете определить здесь экземпляры почти так же, как и сопоставление, описанное ранее:
instance ProductDiscountableParams SomeProcessType where
productDiscountableParamsUserDisabled = userDisabled . entityVal . sptUser
productDiscountableParamsUserHasDiscounts = userHasDiscounts . entityVal . sptUser
productDiscountableParamsOfferProductKey = offerProduct . entityVal . sptOffer
productDiscountableParamsProductKey = entityKey . sptProduct
Что делает так, что вызывающий сайт стал намного чище:
productDiscountable (spt :: SomeProcessType)
Каковы наилучшие практики, которых следует придерживаться в отношении вышеуказанного? Мне кажется, что постоянная попытка уменьшить тип до его наименьшего представления - это не всегда путь к go, но я также не очень хорошо отношусь к передаче сущностей базы данных, и что должен быть более приятный способ для моделирования ввода / вывода задачи.