То, что мой код Clojure делает большую часть времени, это преобразование данных. Тем не менее, я не вижу формы преобразования данных — я должен знать, как эти данные выглядят на входе, и держать мысленную модель их изменения на каждом этапе. Но я делаю ошибки. Я делаю ошибки в своем коде, чтобы данные больше не соответствовали модели, которой они должны следовать. И я допускаю ошибки в своей ментальной модели того, как выглядят данные в настоящее время, что может привести к ошибке кода в дальнейшем. Конечный результат тот же — небольшое полезное исключение на более позднем этапеотносительно неправильной формы данных. Здесь есть две проблемы: Ошибка обычно предоставляет слишком мало полезной информации, и она обычно проявляется позже, чем на самом деле ошибка кода / модели. Поэтому я легко потратил час или больше на устранение этих ошибок. В дополнение к этому, трудно также прочитать такой код, потому что читателю не хватает умственной модели данных автора, и он сам должен извлечь ее — что довольно сложно, особенно если форма входных данных неясна с самого начала ,
Я должен отметить, что я, конечно, пишу тесты и экспериментирую в REPL, но я все еще сталкиваюсь с этими проблемами, поэтому этого мне недостаточно. Тесты не могут защитить меня от неправильной модели входных данных (поскольку я пишу [модульные] тесты, основанные на тех же предположениях, что и код, и обнаруживаю ошибку, только когда я интегрирую все биты), и даже если они помогают обнаружить ошибка, это все еще занимает много времени основной причиной.
Могу ли я сделать лучше? Я верю что смогу.
Трудно выявить ошибки с задержкой манифестации и сложный для понимания код, который сообщает только половину истории (преобразования, но не форму преобразуемых данных) — это цена, которую мы платим за силу динамической типизации. Но есть стратегии по снижению этой цены. Я хочу представить три из них: небольшие, сфокусированные функции с хорошими именами, деструктурирование в качестве документации и разумное использование предварительных и постусловий.
Содержание этого поста основано на том, что я узнал от Улиса Червиньо Берези во время одного из бесплатных часов в офисе Clojure, которые он щедро предлагает , подобно Лейфу в США.
Поэтому нам нужно сделать форму данных более очевидной и быстро потерпеть неудачу, желательно с помощью полезного сообщения об ошибке.
Основная идея:
- Разбейте преобразования на маленькие простые функции с понятными именами
- Используйте деструктурирование в аргументах функции для документирования ожидаемых данных.
- Используйте предварительные и последующие условия (и / или утверждения) как проверки, так и документацию
- (Все тесты и интерактивные исследования в REPL, которые вы уже делаете.)
Упрощенный пример
У нас есть интернет-магазин, который продает автомобили со скидкой. У нас также есть периодические акции с повышенными скидками на отдельные автомобили. Для каждого автомобиля у нас также есть несколько ключевых слов, которые люди могут использовать, чтобы найти его и категории, к которым он принадлежит. Ниже приведен код, который обрабатывает необработанные данные о автомобилях + кампаниях + поиске по ключевым словам из запроса БД: сначала оригинал, а затем рефакторированный с проверками:
(require '[cheshire.core :refer [parse-string]])
(defn- json->data [key m]
(update-in m [key] #(parse-string % true)))
(defn- select-campaign [car+campaigns]
(first car+campaigns))
(defn- jdbc-array-to-set
[key m]
(update-in m [key] #(apply hash-set (.getArray %))))
(defn refine-cars-simple
"Process get-cars query result set - derive additional data, transform values into better ones
There is one row per car and campaign, a car may have more campaigns - we pick the best one.
"
[cars-raw]
(->>
cars-raw
(map (partial json->data :json)) ;; <- this I originally forgot
;; group rows for the same car => [[car1 ..][car2 ..]]
(group-by :id)
(vals)
;; join all car1 vectors into one car ..
(map select-campaign)
(map (fn [car]
(->>
car
(jdbc-array-to-set :category_ref)
(jdbc-array-to-set :keywords))))))
(require '[cheshire.core :refer [parse-string]])
(require '[clojure.set :refer [subset? difference]])
(defn- car? [{:keys [id] :as car}]
(and (map? car) id))
(defn- json->data [key m]
{:pre [(contains? m key) (string? (get m key))], :post [(map? (get % key))]}
(update-in m [key] parse-string true))
(defn- select-campaign [[first-car :as all]]
{:pre [(sequential? all) (car? first-car)], :post [(car? %)]}
first-car)
(defn- jdbc-array-to-set
[key m]
{:pre [(contains? m key) (instance? java.sql.Array (get m key))], :post [(set? (get % key))]}
(update-in m [key] #(apply hash-set (.getArray %))))
(defn group-rows-by-car [cars-raw]
{:pre [(sequential? cars-raw) (car? (first cars-raw))]
:post [(sequential? %) (vector? (first %))]}
(vals (group-by :id cars-raw)))
(defn refine-car [car]
{:pre [(car? car) (:keywords car) (:category_ref car)]}
(->> car
(jdbc-array-to-set :category_ref)
(jdbc-array-to-set :keywords)))
(defn refine-cars-simple
"Process get-cars query result set - derive additional data, transform values into better ones
There is one row per car and campaign, a car may have more campaigns - we pick the best one.
"
[cars-raw]
(->>
cars-raw
(map (partial json->data :json)) ;; <- this I originally forgot
(group-rows-by-car)
(map select-campaign)
(map refine-car)))
(defn empty-array [] (reify java.sql.Array (getArray [_] (object-array []))))
(refine-cars-simple [{:id 1, :json "{\"discount\":5000}", :campaign_discount 3000, :category_ref (empty-array), :keywords (empty-array)}])
Пример из реального мира
У нас есть интернет-магазин, который продает автомобили со скидкой. У каждого автомобиля, который мы продаем, есть базовая скидка (абсолютная сумма или процент), и мы также проводим периодические кампании для выбранных автомобилей. Для каждого автомобиля у нас также есть несколько ключевых слов, которые люди могут использовать, чтобы найти его.
Оригинальный код
Ниже приведен код, который обрабатывает необработанные данные о автомобилях + кампаниях + поиске по ключевым словам из запроса БД, выбирает наиболее подходящую кампанию и рассчитывает окончательную скидку:
(require '[cheshire.core :refer [parse-string]])
(defn db-array [col] (reify java.sql.Array (getArray [_] (object-array col))))
(defn- json->data [data fields]
(map (fn [data]
(reduce (fn [data field]
(assoc data field (parse-string (get data field) true)))
data fields)) data))
(defn- discount-size [discount]
(if (or
(> (:amount discount) 10000)
(> (:percent discount) 5))
:high
:normal))
(defn- jdbc-array-to-set
"Convert a PostgreSQL JDBC4Array inside the map `m` - at the key `k` - into a se"
[key m] (update-in m [key] #(some->> % (.getArray) (into #{}))))
(defn- compute-discount
"Derive the :discount map based on the car's own discount and its active campaign, if applicable"
[car]
(let [{:keys [json campaign_discount_amount campaign_discount_percent]} car
{:keys [discount_amount discount_percent]} json
discount? (:use_campaign car)
amount (if discount?
(apply + (remove nil? [discount_amount campaign_discount_amount]))
discount_amount)
percent (if discount?
(apply + (remove nil? [discount_percent campaign_discount_percent]))
discount_percent)
discount {:amount amount
:percent percent}
discount-size (discount-size discount)
]
(assoc car :discount discount :discount-size discount-size)))
(defn merge-campaigns
"Given vector of car+campaign for a particular car, return a single car map
with a selected campaign.
"
[car+campaigns]
{:pre [(sequential? car+campaigns) :id]}
(let [campaign-ks [:use_campaign :campaign_discount_amount :campaign_discount_percent :active]
car (apply dissoc
(first car+campaigns)
campaign-ks)
campaign (->> car+campaigns
(map #(select-keys % campaign-ks))
(filter :active)
(sort-by :use_campaign) ;; true, if any, will be last
last)]
(assoc car :best-campaign campaign)))
(defn refine-cars
"Process get-cars query result set - derive additional data, transform values into better ones
There is one row per car and campaign, a car may have more campaigns - we pick the best one.
"
[cars-raw]
(->> cars-raw
(#(json->data % [:json]))
(#(map merge-campaigns
(vals (group-by :id %))))
(map (comp ;; reminder - the 1st fn is executed last
compute-discount
(fn [m] (update-in m [:keywords] (partial remove nil?))) ;; {NULL} => []
(partial jdbc-array-to-set :keywords)
(partial jdbc-array-to-set :category_ref)
))
))
(refine-cars [
{:id 1
:json "{\"discount_amount\":9000,\"discount_percent\":0}"
:campaign_discount_amount 2000
:campaign_discount_percent nil
:use_campaign false
:active true
:keywords (db-array ["fast"])
:category_ref (db-array [])}])
Дефекты и я
Первоначально у меня было две [обнаруженные] ошибки в коде, и обе заняли у меня много времени, чтобы исправить — сначала я забыл преобразовать JSON из строки в карту (неверное предположение о входных данных), а затем я запускаю кампании слияния непосредственно в списке списков автомобилей + кампаний вместо их отображения ( предварительное условие ? не помогло обнаружить эту ошибку). Таким образом, преобразования явно слишком подвержены ошибкам.
Трассировки стека не содержали достаточно полезной контекстной информации (хотя более опытный Clojurist наверняка обнаружил бы и исправил коренные причины гораздо быстрее):
## Forgotten ->json: java.lang.NullPointerException: clojure.lang.Numbers.ops Numbers.java: 961 clojure.lang.Numbers.gt Numbers.java: 227 clojure.lang.Numbers.gt Numbers.java: 3787 core/discount-size cars.clj: 13 core/compute-discount cars.clj: 36 ------------- ## Forgotten (map ..): java.lang.ClassCastException: clojure.lang.PersistentVector cannot be cast to clojure.lang.IPersistentMap RT.java:758 clojure.lang.RT.dissoc core.clj:1434 clojure.core/dissoc core.clj:1436 clojure.core/dissoc RestFn.java:142 clojure.lang.RestFn.applyTo core.clj:626 clojure.core/apply cars.clj:36 merge-campaigns ...
рефакторинга
Это код, реорганизованный в более мелкие функции с проверками (и, конечно, его можно значительно улучшить):
(require '[cheshire.core :refer [parse-string]])
(require '[clojure.set :refer [subset? difference ]])
(defn db-array [col] (reify java.sql.Array (getArray [_] (object-array col))))
(defn- json->data [data fields]
{:pre [(sequential? data) (sequential? fields)]}
(map (fn [data]
(reduce (fn to-json [data field]
{:pre [(map? data) (string? (get data field)) (keyword? field)]}
(assoc data field (parse-string (get data field) true)))
data fields)) data))
(defn- discount-size [{:keys [amount percent] :as discount}]
{:pre [(number? amount) (number? percent) (<= 0 amount) (<= 0 percent 100)]
:post [(#{:high :normal} %)]}
(if (or
(> amount 10000)
(> percent 5))
:high
:normal))
(defn- jdbc-array-to-set
"Convert a PostgreSQL JDBC4Array inside the map `m` - at the key `k` - into a se"
[key m]
{:pre [(keyword? key) (map? m) (let [a (key m)] (or (nil? a) (instance? java.sql.Array a)))]}
(update-in m [key] #(some->> % (.getArray) (into #{}))))
(defn car? [{:keys [id] :as car}]
(and (map? car) id))
(defn- compute-discount
"Derive the :discount map based on the car's own discount and its active campaign, if applicable"
[{{:keys [discount_amount discount_percent] :as json} :json
:keys [campaign_discount_amount campaign_discount_percent] :as car}]
{:pre [(car? car) (map? json) (number? discount_amount) (number? discount_percent)]
:post [(:discount %) (:discount-size %)]}
(let [discount? (:use_campaign car)
amount (if discount?
(apply + (remove nil? [discount_amount campaign_discount_amount]))
discount_amount)
percent (if discount?
(apply + (remove nil? [discount_percent campaign_discount_percent]))
discount_percent)
discount {:amount amount
:percent percent}
discount-size (discount-size discount)
]
(assoc car :discount discount :discount-size discount-size)))
(defn select-campaign
"Return a single car map with a selected campaign."
[{:keys [campaigns] :as car}]
{:pre [(car? car) (sequential? campaigns)]
:post [(contains? % :best-campaign)]}
(let [best-campaign (->> campaigns
(filter :active)
(sort-by :use_campaign) ;; true, if any, will be last
last)]
(-> car
(dissoc :campaigns)
(assoc :best-campaign best-campaign))))
(defn nest-campaign [car]
;; :pre check for campaing keys would require too much repetition => an assert instead
{:pre [(car? car)]
:post [((comp map? :campaign) %)]}
(let [ks (set (keys car))
campaign-ks #{:campaign_discount_amount :campaign_discount_percent :use_campaign :active}
campaign (select-keys car campaign-ks)]
(assert (subset? campaign-ks ks)
(str "Campaign keys missing from the car " (:id car) ": "
(difference campaign-ks ks)))
(-> (apply dissoc car campaign-ks)
(assoc :campaign campaign))))
(defn group-rows-by-car [cars-raw]
{:pre [(sequential? cars-raw) (every? map? cars-raw)]
:post [(sequential? %) (every? vector? %)]}
(vals (group-by :id cars-raw)))
(defn join-campaigns [[car+campaign :as all]]
{:pre [(sequential? all) (:campaign car+campaign)]
:post [(:campaigns %)]}
(-> car+campaign
(assoc :campaigns
(map :campaign all))
(dissoc :campaign)))
(defn refine-car [car]
{:pre [(car? car)]
:post [(:discount %)]} ; keywords and :category_ref are optional
(->> car
(jdbc-array-to-set :category_ref)
(jdbc-array-to-set :keywords)
(#(update-in % [:keywords] (partial remove nil?))) ;; {NULL} => []
(select-campaign)
(compute-discount)))
(defn refine-cars
"Process get-cars query result set - derive additional data, transform values into better ones
There is one row per car and campaign, a car may have more campaigns - we pick the best one.
"
[cars-raw]
(->> cars-raw
(#(json->data % [:json]))
(map nest-campaign)
(group-rows-by-car)
(map join-campaigns)
(map refine-car)
))
(refine-cars [
{:id 1
:json "{\"discount_amount\":9000,\"discount_percent\":0}"
:campaign_discount_amount 2000
:campaign_discount_percent nil
:use_campaign false
:active true
:keywords (db-array ["fast"])
:category_ref (db-array [])}])
Downsides
Основная проблема с предварительными и постусловиями заключается в том, что они не предоставляют полезного контекста в своем сообщении об ошибке и не поддерживают добавление пользовательского сообщения. Ошибка как
Assert failed: (let [a (key m)] (or (nil? a) (instance? java.sql.Array a))) cars.clj:18 user/jdbc-array-to-set
Лучше, чем не провалить Fust, но не сказать, каково было недопустимое значение и какое из тысяч автомобилей имело недопустимое значение.
Кроме того, проверки выполняются во время выполнения, поэтому они снижают производительность. Это может быть не проблема с проверками, такими как (map?), Но может быть с f.ex. (Каждый?).
Как насчет дублирования?
Вы повторяете одни и те же проверки снова и снова? Затем вы можете скопировать их с помощью with-meta(они все равно попадут в метаданные) или использовать явно:
(defn with-valid-car [f] (fn [car] {:pre [:make :model :year]} (f car)))
(def count-price (with-valid-car (fn [car] (do-something car))))
;; or make & use a macro to make it nicer
Как насчет статических типов
Это выглядит как хороший пример для статических типов. И да, я с Явы и мне их не хватает. С другой стороны, несмотря на то, что статическая типизация решит основную категорию проблем, она создает новые и имеет свои недостатки.
А) На самом деле у меня довольно много «типов», поэтому для полного моделирования потребуется много классов:
- Необработанные данные из БД — машина с полями кампании и ключевыми словами, category_ref как java.sql.Array
- Автомобиль с ключевыми словами как последовательность
- Автомобиль с category_ref как последовательность
- Автомобиль с вложенным: кампания «объект»
- Автомобиль с вложенным объектом: best-campaign и с: rate (у вас может быть: rate там с начала, изначально установлен на nil, но тогда вам все равно нужно будет убедиться, что конечная функция установит для него значение)
Б) Основным преимуществом Clojure является использование общих структур данных — карт, векторов, ленивых последовательностей — и мощных, легко комбинируемых общих функций, работающих на них. Это делает интеграцию библиотек очень простой, поскольку все это просто карта (а не пользовательский тип, который необходимо преобразовать), и вы всегда можете преобразовать их с помощью своих старых функций хороших друзей — будь то определение запроса Korma SQL, набор результатов или HTTP-запрос. Статические типы убирают это.
C) Типы разрешают только подмножество проверок, которые могут вам понадобиться (то есть, если вы не используете Haskell :)) — они могут проверить, что вещь является автомобилем, но не то, что возвращаемое значение находится в диапазоне 7… 42.
D) Некоторые функции не заботятся о типе, только его малая часть — например, jdbc-array-to-set заботится только о том, чтобы аргумент был картой, имел ключ и, если он установлен, значение было java.sql.Array .
Что еще там?
- core.typed
- призматический / схема
- программирование контрактов с core.contracts
Вывод
Используя меньшие функции и условия до + post, я могу обнаруживать ошибки намного раньше, а также лучше документировать ожидаемую форму данных, особенно в случае деструктуризации в сигнатурах fn. Существует некоторое дублирование в условиях до / после, и сообщения об ошибках мало полезны, но намного лучше. Я предполагаю, что более сложные случаи могут препятствовать использованию core.contracts или даже core.typed / schema.
Какие стратегии вы используете? Что бы вы улучшили? Другие комментарии?
Я призываю вас раскошелиться, улучшить суть и поделиться своим мнением.
Обновления