-
SDK, работа над ошибками
Давайте поможем инженерам из Amazon с их SDK 2.0. Разберем наиболее серьезную ошибку – частичную инициализацию объекта.
В прошлой заметке я упоминал, как создаются объекты в SDK. Если коротко, у классов скрыты конструкторы, и нужно пользоваться билдером. Получается что-то вроде такого:
GetObjectRequest request = GetObjectRequest.builder() .bucket("acme-releases") .key("path/to/file.txt") .ifEtagMatches("....") .ifModifiedSince("...") .build()
Билдер не знает, какие из параметров обязательны, а какие нет. В примере выше код работает. Если убрать вызов .bucket или .key, все скомпилируется, но при запуске получим:
Execution error (IllegalArgumentException) at ....xml.internal.marshall.SimpleTypePathMarshaller Parameter 'Bucket' must not be null
Обратите внимание, что ошибка приехала из какого-то XML-маршаллера, хотя никакого XML и тем более маршаллизации здесь нет. Тело пустое, и просто составляется URL.
Подчеркну: программистам из Амазона вполне ОК, что объект инициирован частично. Спрашивается, что можно сделать с объектом
GetObjectRequest
, если у него не заполнен бакет? Ничего. Зачем тогда позволять такую ситуацию?Как они вообще представляют работу со своим SDK? Пользователь садится и перебором проверяет, какие поля нужны, а какие нет? Ладно я знаю, что бакет и ключ необходимы, но ведь кто-то не знает. И узнает он только когда бахнет прод.
Проблема решается просто. Все поля объекта делятся на обязательные и нет – по аналогии с аргументами
args
иkwargs
в Питоне. Обязательные поля потому так и называются, что без них невозможно дернуть конструктор или порождающий статичный метод. В нашем случае обязательны бакет и ключ. С ними код становится таким:GetObjectRequest request = new GetObjectRequest( "acme-releases", "path/to/file" ).withEtag("...") .withModifiedSince("...")
либо то же самое с билдером:
GetObjectRequest request = GetObjectRequest.builder( "acme-releases", "path/to/file") .etag("...") .modifiedSince("...") .build()
То есть хоть разбейся в лепешку, но обязательные параметры передай.
Ну? Что мешало так сделать? Здесь даже паттерны сохранены, чтобы не пострадало чувство прекрасного.
Заметим, что я не говорю отсебятину: все это сказано в книге Effective Java авторства Джошуа Блоха. Он так и пишет: используйте неизменяемые объекты, не допускайте частичной инициализации, требуйте обязательные поля сразу – не надейтесь, что кто-то заполнит из позже. Кумир джавистов говорит, как делать правильно. Почему в Амазоне решили, что сами с усами?
Впрочем, пока я писал это, подумал – может, все гораздо проще? Может быть, за SDK сажают мидлов и стажеров, пока они без задач? Скажем, наняли стажера, а у тимлида релиз, погружать человека некогда, поэтому его сажают за SDK. Вполне похоже на правду.
У нас так было в Датаарте: пока человек без работы, его сажали за всякий внутренний хлам. Форму заказа пиццы, аукцион парковочных мест, каталог сотрудников. Все это было крайне низкого качества, потому что шло через десятки джунов и мидлов без какого-либо контроля качества.
Может и в Амазоне такой же порядок? Кто знает, расскажите. Потому что чем иначе объяснить такое качество SDK – я не знаю.
-
SOLID
На мой взгляд, у SOLID есть важное и полезное применение — унижать людей на собесах. Очень помогает в найме, когда на должность претендует сто человек, а нам не нужны неудачники. Уверен, в Амазон брали только тех, кто этот SOLID знает. Результат налицо.
О других преимуществах SOLID мне неизвестно.
-
Паттерны
Одна из худших вещей в айти – это паттерны. Когда я слышу “паттерн”, меня трясет. Хочется, чтобы паттернов было как можно меньше, а может быть, однажды мы доживем до счастливого дня, когда в программировании не будет паттернов.
Откуда такой радикализм? Дело в том, что паттерн – это костыль. Помните картинку с жуком, где слева баг, а справа фича? То же самое с паттерном. Это прокол языка, который решили подпереть костылем.
Builder, Visitor, Singleton – все это ошибки в дизайне языка. Чемпионами по их количеству являются C++ и Java. Вряд ли кто-то в здравом уме назовет их хорошо спроектированными языками.
Builder – прямое следствие того, что нет параметров по умолчанию. Visitor – цена за убогую систему типов и ООП в целом. Singleton вообще рак мозга – кто-то решил, что объект нельзя создать дважды, хотя внятного объяснения этому нет.
Если взять любую книгу по Джаве, половина ее будет о том, как делать не надо и какой паттерн брать взамен. Не лучше ли убрать из языка то, чего делать не надо? Я понимаю, легаси и все такое, но все же.
Книги Банды Четырех и талмуды толщиной с руку, где учат паттернам, наводят гнетущее впечатление. Столько труда потрачено на костыли, чтобы обойти ошибки языка!
Паттерн – это о том, как сделать удобней компилятору или IDE. Ни слова о том, чтобы сделать удобно потребителю кода.
Беда паттернов в том, что они плохо влияют на программиста. Он буквально тупеет, не понимая, что по-прежнему пишет плохой код, просто теперь можно сослаться на книги или громкие имена. Мышление паттернами – это натуральная деградация. Есть надежда, если Джава-программист поддерживает форму, ковыряясь с Лиспом или Хаскелем. Но если он видит только паттерны с десяти до шести, это путь к деградации и больше никуда.
Пример из реальной жизни – Amazon AWS SDK для Джавы. Казалось бы: самое популярное облако, самый популярный язык, самые сильные программисты. Почему же они пишут абсолютное говно, которым нельзя пользоваться? Потому что в голове паттерны, а не задача сделать удобно.
Если вы не знаете, все пакеты SDK V1 следуют паттерну POJO. Это когда каждый класс SDK имеет нулевой конструктор и пачку геттеров и сеттеров. Например, какой-нибудь GetObjectRequest создается так:
GetObjectRequest request = new GetObjectRequest(); request.setBucket("some_bucket") request.setKey("path/to/file.txt") request.setIfModifiedSince(...)
И таких классов тысячи – в буквальном смысле. Нигде не сказано, какой из сеттеров является обязательным. Можно запросто убрать вызов
setBucket
, и запрос будет без бакета. При запуске кода вы получите исключение в рантайме.Паттерн есть? Есть. Удобно? Нет. Вы же не будете гонять код вручную на S3 каждый раз, когда меняете какой-то сеттер. Поэтому все сводится к тому, чтобы поправить код, прогнать CI и задеплоить на тестовое окружение, чтобы дернуть там и убедиться, что работает. Займет час. Умные ребята поднимают для этого Докер с локальным min.io, но так бывает не везде.
Написав тысячи битых классов, инженеры Амазона почесали голову и сели за AWS SDK V2.0. На этот раз с другим паттерном – Builder. Теперь каждый класс создается цепочкой:
GetObjectRequest request = GetObjectRequest.builder() .bucket("some_bucket") .key("path/to/file.txt") .ifModifiedSince(...) .build();
Казалось бы, удобно, современно, все как у Джошуа Блоха. Однако я по-прежнему не вижу, какие параметры обязательны. Легко пропустить метод .bucket и получить объект, который не знает, в какой бакет обращаться.
А вот реальный пример с тегом. Тег – это объект, который хранит пару ключ-значение. Других полей у него нет. Казалось бы, сделай конструктор с двумя полями, чтобы было так:
Tag tag = new Tag("release", "test-123.4");
Но нельзя, у нас же паттерн! Нужен билдер и методы для каждого поля. В результате имеем:
Tag tag = Tag.builder().value("42").build(); #object[....s3.model.Tag ... "Tag(Value=42)"]
Смотрю и не понимаю – как так? Почему позволено иметь тег, в котором нет имени поля?! Оно же Null и в будущем свалится с ошибкой. Как вообще можно создать тег без имени и значения? Кто был тот тупица, который все это дизайнил? Кто нанял клоунов в Амазон за 250 тысяч в год, чтобы они писали подобный код?
Они наколбасили сто новых пакетов и классов с новым паттерном. Но как было невозможно пользоваться, так и осталось. Как так получилось?
Понятно, это вопросы в никуда. Программисты пишут SDK по паттерну – как сказал знакомый джавист, “ходят строем”. Главное, чтобы строй шел ровно, а куда он придет – и надо ли было идти – мало кто задается вопросом.
Пожелаю читателю использовать паттерны как можно меньше. Если вы слышите “паттерн” – это тревожный знак.
-
Clojure AntiPatterns: the with-retry macro
Most of clojurians write good things about Clojure only. I decided to start sharing techniques and patterns that I consider bad practices. We still have plenty of them in Clojure projects, unfortunately.
My first candidate is widely used, casual macro called
with-retry
:(defmacro with-retry [[attempts timeout] & body] `(loop [n# ~attempts] (let [[e# result#] (try [nil (do ~@body)] (catch Throwable e# [e# nil]))] (cond (nil? e#) result# (> n# 0) (do (Thread/sleep ~timeout) (recur (dec n#))) :else (throw (new Exception "all attempts exhausted" e#))))))
This is a very basic implementation. It catches all possible exceptions, has a strict number of attempts, and the constant delay time. Typical usage:
(with-retry [3 2000] (get-file-from-network "/path/to/file.txt"))
Should network blink, most likely you’ll get a file anyway.
Clojure people who don’t like macros write a function like this:
(defn with-retry [[attempts timeout] func] (loop [n attempts] (let [[e result] (try [nil (func)] (catch Throwable e [e nil]))] (cond (nil? e) result (> n 0) (do (Thread/sleep timeout) (recur (dec n))) :else (throw (new Exception "all attempts exhausted" e))))))
It acts the same but accepts not arbitrary code but a function. A form can be easily turned into a function by putting a sharp sign in front of it. After all, it looks almost the same:
(with-retry [3 2000] #(get-file-from-network "/path/to/file.txt"))
Although it is considered being a good practice, here is the outcome of using it in production.
Practice proves that, even if you wrap something into that macro, you cannot recover from a failure anyway. Imagine you’re downloading a file from S3 and pass wrong credentials. You cannot recover no matter how many times you retry. Wrong creds remain wrong forever. Now there is a missing file: again, no matter how hard you retry, it’s all in vain and you only waste resources. Should you put a file into S3, and submit wrong headers, it’s the same. If your network is misconfigured or some resources are blocked, or you have no permissions, it’s the same again: no matter how long have you been trying, it’s useless.
There might be dozens of reasons when your request fails, and there is no way to recover. Instead of invoking a resource again and again, you must investigate what went wrong.
There might be some rare cases which are worth retrying though. One of them is an
IOException
caused by a network blink. But in fact, modern HTTP clients already handle it for you. If youGET
a resource and receive anIOException
, most likely your client has already done three attempts silently with growing timeouts. By wrapping the callwith-retry
, you perform 9 attempts or so under the hood.Another case might be 429 error code which stands for rate limitation on the server side. Personally I don’t think that a slight delay may help. Most likely you need to bump the limits, rotate API keys and so on but not
Thread.sleep
in the middle of code.I’ve seen terrible usage of
with-retry
macro across various projects. One developer specified 10 attempts with 10 seconds timeout to reach a remote API for sure. But he was calling the wrong API handler in fact.Another developer put two nested
with-macro
forms. They belonged to different functions and thus could not be visible at once. I’m reproducing a simplified version:(with-retry [4 1000] (do-this ...) (do-that ...) (with-retry [3 2000] (do-something-else...)))
According to math, 4 times 3 is 12. When the
(do-something-else)
function failed, the whole top-level block started again. It led to 12 executions in total with terrible side effects and logs which I could not investigate.One more case: a developer wrapped a chunk of logic that inserted something into the database. He messed up with foreign keys so the records could not be stored. Postgres replied with an error “foreign key constraint violation” yet the macro tried to store them three times before failing completely. Three broken SQL invocations… for what? Why?
So. Whenever you use
with-retry
, most likely it’s a bad sign. Most often you cannot recover from a failure no matter if you add two numbers, upload a file, or write into a database. You should only retry in certain situations likeIOException
or rate limiting. But even those cases are questionable and might be mitigated with no retrying.Next time you’re going to cover a block of logic
with-retry
, think hard if you really need to retry. Will it really help in case of wrong creds, a missing file, incorrect signature or similar things? Perhaps not. Thus, don’t retry in vain. Just fail and write detailed logs. Then find the real problem, fix it and let it never happen again. -
Глобальное потепление
Уже год я преподаю английский: одному человеку и бесплатно. Первые полгода занимались по моей программе, которую я составил по книге Мерфи. Затем перешли на учебник Language Leader, по которому я учился когда-то сам.
Он хорошо составлен: он интересен взрослому, потому что авторы добавили много фактов из реальной жизни. Из него буквально фонит то, что было в мире на момент его составления. В том числе поэтому проходить некоторые темы забавно.
Одна из таких тем — погода. Лет 10-15 назад весь мир стоял на ушах из-за так называемого “глобального потепления”. Ему приписывались любые природные явления: шторм, торнадо — потепление, озоновая дыра — потепление, ядовитые испарения в тундре — потепление.
Забавно, как в учебнике повторяется то же самое. Упражнения самых разных форматов — текст, диалоги, грамматика, — и везде потепление, потепление, потепление. Других причин не бывает. А если и есть причина, то какая у нее первопричина? Угадайте.
Поэтому я воспринимаю учебник как исторический документ. Читая его, видно, как штырило людей на эту тему в прошлом. Гринпис и прочие радикальные группировки штурмовали платформы Шелл, а их отгоняли водометами. Население “прикладывало линейку” — если сегодня потеплело на градус, а завтра на два, то через месяц потеплеет на тридцать.
Сегодня, когда глобальное потепление уже выдохлось, повестка изменилась. Теперь у нас “борьба с изменением климата”. Это более гибкая конструкция, потому что нигде не говориться, как долго нужно бороться. Горизонт работ заложен уже в постановке задачи.
Грета куда-то пропала, а у меня смешной стикер-пак с ней. Пропадает без дела.
Хороших выходных!
-
PG2 release 0.1.15
PG2 version 0.1.15 is out. This version mostly ships improvements to connection pool and folders (reducers) of a database result. There are two new sections in the documentation that describe each part. I reproduce them below.
Connection Pool
Problem: every time you connect to the database, it takes time to open a socket, pass authentication pipeline and receive initial data from the server. From the server’s prospective, a new connection spawns a new process which is also an expensive operation. If you open a connection per a query, your application is about ten times slower than it could be.
Connection pools solve that problem. A pool holds a set of connections opened in advance, and you borrow them from a pool. When borrowed, a connection cannot be shared with somebody else any longer. Once you’ve done with your work, you return the connection to the pool, and it’s available for other consumers.
PG2 ships a simple and robust connection pool out from the box. This section covers how to use it.
A Simple Example
Import both core and pool namespaces as follows:
(ns demo (:require [pg.core :as pg] [pg.pool :as pool]))
Here is how you use the pool:
(def config {:host "127.0.0.1" :port 5432 :user "test" :password "test" :database "test"}) (pool/with-pool [pool config] (pool/with-connection [conn pool] (pg/execute conn "select 1 as one")))
The
pool/with-pool
macro creates a pool object from theconfig
map and binds it to thepool
symbol. Once you exit the macro, the pool gets closed.The
with-pool
macro can be easily replaced with thewith-open
macro and thepool
function that creates a pool instance. By exit, the macro calls the.close
method of an opened object, which closes the pool.(with-open [pool (pool/pool config)] (pool/with-conn [conn pool] (pg/execute conn "select 1 as one")))
Having a pool object, use it with the
pool/with-connection
macro (there is a shorter versionpool/with-conn
as well). This macro borrows a connection from the pool and binds it to theconn
symbol. Now you pass the connection topg/execute
,pg/query
and so on. By exiting thewith-connection
macro, the connection is returned to the pool.And this is briefly everything you need to know about the pool! Sections below describe more about its inner state and behavior.
Configuration
The pool object accepts the same config the
Connection
object does section for the table of parameters). In addition to these, the fillowing options are accepted:Field Type Default Comment :pool-min-size
integer 2 Minimum number of open connections when initialized. :pool-max-size
integer 8 Maximum number of open connections. Cannot be exceeded. :pool-expire-threshold-ms
integer 300.000 (5 mins) How soon a connection is treated as expired and will be forcibly closed. :pool-borrow-conn-timeout-ms
integer 15.000 (15 secs) How long to wait when borrowing a connection while all the connections are busy. By timeout, an exception is thrown. The first option
:pool-min-size
specifies how many connection are opened at the beginning. Setting too many is not necessary because you never know if you application will really use all of them. It’s better to start with a small number and let the pool to grow in time, if needed.The next option
:pool-max-size
determines the total number of open connections. When set, it cannot be overridden. If all the connections are busy and there is still a gap, the pool spawns a new connection and adds it to the internal queue. But if the:pool-max-size
value is reached, an exception is thrown.The option
:pool-expire-threshold-ms
specifies the number of milliseconds. When a certain amount of time has passed since the connection’s initialization, it is considered expired and will be closed by the pool. This is used to rotate connections and prevent them from living for too long.The option
:pool-borrow-conn-timeout-ms
prescribes how long to wait when borrowing a connection from an exhausted pool: a pool where all the connections are busy and the:pool-max-size
value is reached. At this case, the only hope that other clients complete their work and return theri connection before timeout bangs. Should there still haven’t been any free connections during the:pool-borrow-conn-timeout-ms
time window, an exception pops up.Pool Methods
The
stats
function returns info about free and used connections:(pool/with-pool [pool config] (pool/stats pool) ;; {:free 1 :used 0} (pool/with-connection [conn pool] (pool/stats pool) ;; {:free 0 :used 1} ))
It might be used to send metrics to Grafana, CloudWatch, etc.
Manual Pool Management
The following functions help you manage a connection pool manually, for example when it’s wrapped into a component (see Component and Integrant libraries).
The
pool
function creates a pool:(def POOL (pool/pool config))
The
used-count
andfree-count
functions return total numbers of busy and free connections, respectively:(pool/free-count POOL) ;; 2 (pool/used-count POOL) ;; 0
The
pool?
predicate ensures it’s aPool
instance indeed:(pool/pool? POOL) ;; true
Closing
The
close
method shuts down a pool instance. On shutdown, first, all the free connections get closed. Then the pool closes busy connections that were borrowed. This might lead to failures in other threads, so it’s worth waiting until the pool has zero busy connections.(pool/close POOL) ;; nil
The
closed?
predicate ensures the pool has already been closed:(pool/closed? POOL) ;; true
Borrow Logic in Detail
When getting a connection from a pool, the following conditions are taken into account:
- if the pool is closed, an exception is thrown;
- if there are free connections available, the pool takes one of them;
- if a connection is expired (was created long ago), it’s closed and the pool performs another attempt;
- if there aren’t free connections, but the max number of used connection has not been reached yet, the pool spawns a new connection;
- if the number of used connections is reached, the pool waits for
:pool-borrow-conn-timeout-ms
amount of milliseconds hoping that someone releases a connection in the background; - by timeout (when nobody did), the pool throws an exception.
Returning Logic in Detail
When you return a connection to a pool, the following cases might come into play:
- if the connection is an error state, then transaction is rolled back, and the connection is closed;
- if the connection is in transaction mode, it is rolled back, and the connection is marked as free again;
- if it was already closed, the pool just removes it from used connections. It won’t be added into the free queue;
- if the pool is closed, the connection is removed from used connections;
- when none of above conditions is met, the connection is removed from used and becomes available for other consumers again.
This was the Connecton Pool section, and now we proceed with Folders.
Folders (Reducers)
Folders (which are also known as reducers) are objects that transform rows from network into something else. A typical folder consists from an initial value (which might be mutable) and logic that adds the next row to that value. Before returning the value, a folder might post-process it somehow, for example turn it into an immutable value.
The default folder (which you don’t need to specify) acts exactly like this: it spawns a new
transient
vector andconj!
es all the incoming rows into it. Finally, it returns apersistent!
version of this vector.PG2 provides a great variety of folders: to build maps or sets, to index or group rows by a certain function. With folders, it’s possible to dump a database result into a JSON or EDN file.
It’s quite important that folders process rows on the fly. Like transducers, they don’t keep the whole dataset in memory. They only track the accumulator and the current row no matter how many of them have arrived from the database: one thousand or one million.
A Simple Folder
Technically a folder is a function (an instance of
clojure.lang.IFn
) with three bodies of arity 0, 1, and 2, as follows:(defn a-folder ([] ...) ([acc] ...) ([acc row] ...))
-
The first 0-arity form produces an accumulator that might be mutable.
-
The third 2-arity form takes the accumulator and the current row and returns an updated version of the accumulator.
-
The second 1-arity form accepts the last version of the accumulator and transforms it somehow, for example seals a transient collection into its persistent view.
Here is the
default
folder:(defn default ([] (transient [])) ([acc!] (persistent! acc!)) ([acc! row] (conj! acc! row)))
Some folders depend on initial settings and thus produce folding functions. Here is an example of the
map
folder that acts like themap
function fromclojure.core
:(defn map [f] (fn folder-map ([] (transient [])) ([acc!] (persistent! acc!)) ([acc! row] (conj! acc! (f row)))))
Passing A Folder
To pass a custom folder to process the result, specify the
:as
key as follows:(require '[pg.fold :as fold]) (defn row-sum [{:keys [field_1 field_2]}] (+ field_1 field_2)) (pg/execute conn query {:as (fold/map row-sum)}) ;; [10 53 14 32 ...]
Standard Folders and Aliases
PG provides a number of built-in folders. Some of them are used so often that it’s not needed to pass them explicitly. There are shortcuts that enable certain folders internally. Below, find the actual list of folders, their shortcuts and examples.
Column
Takes a single column from each row returning a plain vector:
(pg/execute conn query {:as (fold/column :id)}) ;; [1 2 3 4 ....]
There is an alias
:column
that accepts a name of the column:(pg/execute conn query {:column :id}) ;; [1 2 3 4 ....]
Map
Acts like the standard
map
function fromclojure.core
. Applies a function to each row and collects a vector of results.Passing the folder explicitly:
(pg/execute conn query {:as (fold/map func)})
And with an alias:
(pg/execute conn query {:map func})
Default
Collects unmodified rows into a vector. That’s unlikely you’ll need that folder as it gets applied internally when no other folders were specified.
Dummy
A folder that doesn’t accumulate the rows but just skips them and returns nil.
(pg/execute conn query {:as fold/dummy}) nil
First
Perhaps the most needed folder,
first
returns the first row only and skips the rest. Pay attention, this folder doesn’t have a state and thus doesn’t need to be initiated. Useful when you query a single row by its primary key:(pg/execute conn "select * from users where id = $1" {:params [42] :as fold/first}) {:id 42 :email "test@test.com"}
Or pass the
:first
(or:first?
) option set to true:(pg/execute conn "select * from users where id = $1" {:params [42] :first true}) {:id 42 :email "test@test.com"}
Index by
Often, you select rows as a vector and build a map like
{id => row}
, for example:(let [rows (jdbc/execute! conn ["select ..."])] (reduce (fn [acc row] (assoc acc (:id row) row)) {} rows)) {1 {:id 1 :name "test1" ...} 2 {:id 2 :name "test2" ...} 3 {:id 3 :name "test3" ...} ... }
This process is known as indexing because later on, the map is used as an index for quick lookups.
This approach, although is quite common, has flaws. First, you traverse rows twice: when fetching them from the database, and then again inside
reduce
. Second, it takes extra lines of code.The
index-by
folder does exactly the same: it accepts a function which is applied to a row and uses the result as an index key. Most often you pass a keyword:(let [query "with foo (a, b) as (values (1, 2), (3, 4), (5, 6)) select * from foo" res (pg/execute conn query {:as (fold/index-by :a)})] {1 {:a 1 :b 2} 3 {:a 3 :b 4} 5 {:a 5 :b 6}})
The shortcut
:index-by
accepts a function as well:(pg/execute conn query {:index-by :a})
Group by
The
group-by
folder is simlar toindex-by
but collects multiple rows per a grouping function. It produces a map like{(f row) => [row1, row2, ...]}
whererow1
,row2
and the rest return the same value forf
.Imagine each user in the database has a role:
{:id 1 :name "Test1" :role "user"} {:id 2 :name "Test2" :role "user"} {:id 3 :name "Test3" :role "admin"} {:id 4 :name "Test4" :role "owner"} {:id 5 :name "Test5" :role "admin"}
This is what
group-by
returns when grouping by the:role
field:(pg/execute conn query {:as (fold/group-by :role)}) {"user" [{:id 1, :name "Test1", :role "user"} {:id 2, :name "Test2", :role "user"}] "admin" [{:id 3, :name "Test3", :role "admin"} {:id 5, :name "Test5", :role "admin"}] "owner" [{:id 4, :name "Test4", :role "owner"}]}
The folder has its own alias which accepts a function:
(pg/execute conn query {:group-by :role})
KV (Key and Value)
The
kv
folder accepts two functions: the first one is for a key (fk
), and the second is for a value (fv
). Then it produces a map like{(fk row) => (fv row)}
.A typical example might be a narrower index map. Imagine you select just a couple of fields,
id
andemail
. Now you need a map of{id => email}
for quick email lookup by id. This is wherekv
does the job for you.(pg/execute conn "select id, email from users" {:as (fold/kv :id :email)}) {1 "ivan@test.com" 2 "hello@gmail.com" 3 "skotobaza@mail.ru"}
The
:kv
alias accepts a vector of two functions:(pg/execute conn "select id, email from users" {:kv [:id :email]})
Run
The
run
folder is useful for processing rows with side effects, e.g. printing them, writing to files, passing via API. A one-argument function passed torun
is applied to each row ignoring the result. The folder counts a total number of rows being processed.(defn func [row] (println "processing row" row) (send-to-api row)) (pg/execute conn query {:as (fold/run func)}) 100 ;; the number of rows processed
An example with an alias:
(pg/execute conn query {:run func})
Table
The
table
folder returns a plain matrix (a vector of vectors) of database values. It reminds thecolumns
folder but also keeps column names in the leading row. Thus, the resulting table always has at least one row (it’s never empty because of the header). The table view is useful when saving the data into CSV.The folder has its inner state and thus needs to be initialized with no parameters:
(pg/execute conn query {:as (fold/table)}) [[:id :email] [1 "ivan@test.com"] [2 "skotobaza@mail.ru"]]
The alias
:table
accepts any non-false value:(pg/execute conn query {:table true}) [[:id :email] [1 "ivan@test.com"] [2 "skotobaza@mail.ru"]]
Java
This folder produces
java.util.ArrayList
where each row is an instance ofjava.util.HashMap
. It doesn’t require initialization:(pg/execute conn query {:as fold/java})
Alias:
(pg/execute conn query {:java true})
Reduce
The
reduce
folder acts like the same-name function fromclojure.core
. It accepts a function and an initial value (accumulator). The function accepts the accumulator and the current row, and returns an updated version of the accumulator.Here is how you collect unique pairs of size and color from the database result:
(defn ->pair [acc {:keys [sku color]}] (conj acc [a b])) (pg/execute conn query {:as (fold/reduce ->pair #{})}) #{[:xxl :green] [:xxl :red] [:x :red] [:x :blue]}
The folder ignores
reduced
logic: it performs iteration until all rows are consumed. It doesn’t check if the accumulator is wrapped withreduced
.The
:reduce
alias accepts a vector of a function and an initial value:(pg/execute conn query {:reduce [->pair #{}]})
Into (Transduce)
This folder mimics the
into
logic when it deals with anxform
, also known as a transducer. Sometimes, you need to pass the result throughout a bunch ofmap
/filter
/keep
functions. Each of them produces an intermediate collection which is not as fast as it could be with a transducer. Transducers are designed such that they compose a stack of actions, which, when being run, does not produce extra collections.The
into
folder accepts anxform
produced bymap
/filter
/comp
, whatever. It also accepts a persistent collection which acts as an accumulator. The accumulator gets transformed into a transient view internally for better performance. The folder usesconj!
to push values into the accumulator, so maps are not acceptable, only vectors, lists, or sets. When the accumulator is not passed, it’s an empty vector.Here is a quick example of
into
in action:(let [tx (comp (map :a) (filter #{1 5}) (map str)) query "with foo (a, b) as (values (1, 2), (3, 4), (5, 6)) select * from foo"] (pg/execute conn query {:as (fold/into tx)})) ;; ["1" "5"]
Another case where we pass a non-empty set to collect the values:
(pg/execute conn query {:as (fold/into tx #{:a :b :c})}) ;; #{:a :b :c "1" "5"}
The
:into
alias is a vector where the first item is anxform
and the second is an accumulator:(pg/execute conn query {:into [tx []]})
To EDN
This folder writes down rows into an EDN file. It accepts an instance of
java.io.Writer
which must be opened in advance. The folder doesn’t open nor close the writer as these actions are beyond its scope. A common pattern is to wrappg/execute
orpg/query
invocations with thewith-open
macro that handles closing procedure even in case of an exception.The folder writes down rows into the writer using
pr-str
. Each row takes one line, and the lines are split with\n
. The leading line is[
, and the trailing is]
.The result is a number of rows processed. Here is an example of dumping rows into a file called “test.edn”:
(with-open [out (-> "test.edn" io/file io/writer)] (pg/execute conn query {:as (fold/to-edn out)})) ;; 199
Let’s check the content of the file:
[ {:id 1 :email "test@test.com"} {:id 2 :email "hello@test.com"} ... {:id 199 :email "ivan@test.com"} ]
The alias
:to-edn
accepts a writer object:(with-open [out (-> "test.edn" io/file io/writer)] (pg/execute conn query {:to-edn out}))
To JSON
Like
to-edn
but dumps rows into JSON. Accepts an instance ofjava.io.Writer
. Writes rows line by line with no pretty printing. Lines are joined with a comma. The leading and trailing lines are square brackets. The result is the number of rows put into the writer.(with-open [out (-> "test.json" io/file io/writer)] (pg/execute conn query {:as (fold/to-json out)})) ;; 123
The content of the file:
[ {"b":2,"a":1}, {"b":4,"a":3}, // ... {"b":6,"a":5} ]
The
:to-json
alias accepts a writer object:(with-open [out (-> "test.json" io/file io/writer)] (pg/execute conn query {:to-json out}))
For more details, you’re welcome to the readme file of the repo.
-
Перекладывание
Иногда говорят: это тебе не джейсоны перекладывать, тут думать надо. А между прочим, перекладывать джейсоны – очень трудное занятие.
Прилетает вам джейсон из сети. Надо его прочитать, провалидировать, распарсить даты, подрезать лишнее. Потом выгрести из базы то, из кэша се, из S3 третье и все это собрать в нечто нужное бизнесу. Сделать эксельку, положить в S3 и отписать в очередь.
На каждом шаге может быть сто причин для эксепшена. Записать логи, не раскрывая бизнес-данных и секретов, собрать исключения и отправить в Сентри.
Иные джейсоны пятиэтажной вложенности. Нужно рыскать по их кишкам и строить обратные мапы (индексы). Потом передавать по пять индексов в другие функции.
Читать из базы миллион джейсонов так, чтобы не умереть от нехватки памяти в AWS.
В идеале покрыть все случаи тестами, желательно с Докером, чтобы были настоящие база, Редис, S3 и так далее.
Все еще легко, на ваш взгляд? Не знаю, мне кажется трудным. Поэтому над “перекладыванием” я не смеюсь.
-
Excel и CSV
Маленькая техническая заметка. Не пользуйтесь CSV в надежде, что он откроется в Экселе. Если ваши потребители – люди с Экселем (а таких большинство), нужно генерить
.xlsx
, а не.csv
.Дело в том, что Эксель писали в Микрософте. Может быть, сегодняшний MS уже обрел какую-то человечность. Но Эксель отсчитывает возраст с 1985 года – прямо как я – и старше многих читателей этой заметки. Поэтому ни о какой человечности говорить не приходится.
Эксель никогда не откроет CSV без ошибок. Он обязательно промажет с разделителем: если в файле запятая, он ищет точку с запятой и наоборот. Для обхода придумали грубый костыль: в первой строке может быть выражение sep=, и тогда Эксель возьмет запятую. Но этот заголовок ломает парсеры CSV, которые ни о каком Экселе не слышали.
Разделитель по умолчанию может зависеть от локали. У француза откроется, а у австрийца не откроется.
Эксель по-прежнему игнорирует UTF8. Немецкие умляуты становятся кракозябрами. Махинации с меткой BOM ни к чему не приводят.
В Экселе есть мастер импорта из CSV, но можно подумать, людям больше нечем заняться, как импортировать что-то куда-то ради таблички.
В общем, нужно напрячь булки и выкинуть CSV, и вместо этого генерить нормальный Эксель.
Если вдруг у вас Джава или Кложа, берите fastexcel и fastexcel-reader – они быстрее и компилируются Граалем. Все, что основано на Apache POI, тормозное и не компилируется Граалем. Я эту дорогу прошел и вот делюсь с вами.
-
Мои объявления
Авито, страница “Мои объявления” — разве это не забавно? На экране все что угодно, кроме моих объявлений. Огромная плашка, громадные пустоты. Слоеный дизайн, когда каждый слой, пусть даже занимает сантиметр в ширину, растекается на весь экран.
Очередной калека-дизайнер, которому “не хватило места”. Важная часть уехала на экран ниже — потому что первый экран занял всякий шлак.
Считаю, таких дизайнеров надо даже не учить, а лечить. Обучение бессильно, пусть действуют профильные специалисты.
-
Загрузка в Амазоне
У веб-панели S3 есть особенность: если скачать оттуда файл, Амазон поправит расширение в зависимости от Content-Type, который назначили файлу при создании. Например, если у файла нет расширения, а Content-Type равен
application/json
, то Амазон допишет в конец .json, чтобы файлик открылся.Казалось бы, хорошо? А вот что имеем на практике.
Если залить файл
hello.json.gzip
, внутри которого сжатый Gzip-ом JSON, и указать заголовкиContent-Type: application/json
,Content-Encoding: gzip
, то при загрузке произойдет следующее.Файл будет декодирован Амазоном, чтобы клиенту не пришлось делать это руками. Не бог весть какая помощь, потому что и текстовые редакторы, и файловые менеджеры открывают gzip-файлы. Но ладно.
После раскодировки Амазон смотрит: что там внутри? Application/json? Значит удалим
.gzip
и добавим.json
. В результате получается файлhello.json.json
. Я не шучу, проверьте сами.Второй случай: я залил в Амазон файл
report.xlsx
, но указал не тот Content-Type. Указал старыйapplication/vnd.ms-excel
для xls документов, а надо было такую колбасу:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
. При загрузке Амазон молча исправил расширение с.xlsx
на.xls
. А Эксель тоже хорош: по клику на файл он пишет, что формат битый, ничего не знаю – нет бы первые 100 байтов проверить, тупица.На ровном месте Амазон заруинил файл, хотя никто об этом не просил.
Понимаете, не нужно мне помогать! Не нужно что-то тайно переименовывать для моей же пользы. Если прям чешется в одном месте – спроси, и я нажму “больше не спрашивать”.
Кроме того, надо помнить: в Амазоне работают не боги, а такие же кодеры, как и везде. Перед нами обычный баг, который живет в проде не один год, и никому нет дела. Баг состоит в том, что махинации с расширением нужно производить только если у файла нет расширения. Вдобавок у расширения приоритет выше, чем у Content-Type, потому что последний – это метаинформация, которая может потеряться или исказиться. Вероятность потерять расширение гораздо ниже, нежели Content-Type.
Верю, что когда-нибудь в Амазоне это поймут.