-
REPL, Cider, Emacs (часть 3/4)
Все части
Оглавление
Пространства имен
При работе с REPL мы всегда находимся в каком-то пространстве. По умолчанию оно написано в приглашении:
user=> (+ 1 2)
Если перейти в другое пространство, изменится и приглашение:
user=> (in-ns 'foobar) foobar=>
Код, что мы вводим в REPL, вычисляется в текущем пространстве. Если объявить в модуле
user
переменную:(in-ns 'user) (def number 1)
, а затем сослаться на нее в пространстве
foobar
, получим ошибку, что символnumber
не найден в текущем контексте:(in-ns 'foobar) (+ 1 number) Syntax error compiling at (repl-chapter:localhost:53495(clj)*:1:8440). Unable to resolve symbol: number in this context
Другой пример: объявим в модулях
user
иfoobar
переменныеnumber
со значениями 1 и 2. Теперь одна и та же форма(inc number)
даст разный результат в зависимости от того, какое пространство текущее. Поэтому перед вычислением мы должны убедиться, что находимся в нужном пространстве имен.Чтобы уберечь нас от подобных ошибок, nREPL учитывает параметр
ns
в сообщениях. Когда мы выполняем код при помощиcider-eval-...
, в сообщении, помимо полейop
иcode
, передаетсяns
. Его значение Cider находит из формы(ns...)
в начале файла. Вычисляя форму, сервер временно меняет пространство, и результат совпадает с тем, что ожидают. -
Zippo: additions to the standard clojure.zip package.
The
clojure.zip
package is a masterpiece yet misses some utility functions. For example, finding locations, bulk updates, lookups, breadth-first traversing and so on. Zippo, the library I’m introducing in this post, brings some bits of missing functionality.Installation
Lein:
[com.github.igrishaev/zippo "0.1.0"]
Deps.edn
{com.github.igrishaev/zippo {:mvn/version "0.1.0"}}
Usage & examples
First, import both Zippo and
clojure.zip
:(ns zippo.core-test (:require [clojure.zip :as zip] [zippo.core :as zippo]))
Declare a zipper:
(def z (zip/vector-zip [1 [2 3] [[4]]]))
Now check out the following Zippo functions.
A finite seq of locations
The
loc-seq
funtion takes a location and returns a lazy seq of locations untill it reaches the end:(let [locs (zippo/loc-seq z)] (mapv zip/node locs)) ;; get a vector of notes to reduce the output [[1 [2 3] [[4]]] 1 [2 3] 2 3 [[4]] [4] 4]
This is quite useful to traverse a zipper without keeping in mind the ending condition (
zip/end?
).Finding locations
The
loc-find
function looks for the first location that matches a predicate:(let [loc (zippo/loc-find z (fn [loc] (-> loc zip/node (= 3))))] (is (= 3 (zip/node loc))))
Above, we found a location which node equals 3.
The
loc-find-all
function finds all the locatins that match the predicate:(let [locs (zippo/loc-find-all z (zippo/->loc-pred (every-pred int? even?)))] (is (= [2 4] (mapv zip/node locs))))
Since the predicate accepts a location, you can check its children, siblings and so on. For example, check if a location belongs to a special kind of parent.
However, most of the time you’re interested in a value (node) rather than a location. The
->loc-pred
function converts a node predicate, which accepts a node, into a location predicate. In the example above, the line(zippo/->loc-pred (every-pred int? even?))
makes a location predicate which node is an even integer.
Updating a zipper
Zippo offers some functions to update a zipper.
The
loc-update
one takes a location predicate, an update function and the rest arguments. Here is how you douple all the even numbers in a nested vector:(let [loc (zippo/loc-update z (zippo/->loc-pred (every-pred int? even?)) zip/edit * 2)] (is (= [1 [4 3] [[8]]] (zip/root loc))))
For the updating function, one may use
zip/append-child
to append a child,zip/remove
to drop the entire location and so on:(let [loc (zippo/loc-update z (fn [loc] (-> loc zip/node (= [2 3]))) zip/append-child :A)] (is (= [1 [2 3 :A] [[4]]] (zip/root loc))))
The
node-update
function is similar but acts on nodes. Instead ofloc-pred
andloc-fn
, it acceptsnode-pred
andnode-fn
what operate on nodes.(let [loc (zippo/node-update z int? inc)] (is (= [2 [3 4] [[5]]] (zip/root loc))))
Slicing a zipper by layers
Sometimes, you need to slice a zipper on layers. This is what is better seen on a chart:
+---ROOT---+ ;; layer 1 | | +-A-+ +-B-+ ;; layer 2 | | | | | | X Y Z J H K ;; layer 3
- Layer 1 is
[Root]
; - Layer 1 is
[A B]
; - Layer 3 is
[X Y Z J H K]
The
loc-layers
function takes a location and builds a lazy seq of layers. The first layer is the given location, then its children, the children of children and so on.(let [layers (zippo/loc-layers z)] (is (= '(([1 [2 3] [[4]]]) (1 [2 3] [[4]]) (2 3 [4]) (4)) (for [layer layers] (for [loc layer] (zip/node loc))))))
Breadth-first seq of locations
The
clojure.zip
package uses depth-first method of traversing a tree. Let’s number the items:+-----ROOT[1]----+ | | +----A[2]---+ +---B[6]--+ | | | | | | X[3] Y[4] Z[5] J[7] H[8] K[9]
This sometimes may end up with an infinity loop when you generate children on the fly.
The
loc-seq-breadth
functions offers the opposite way of traversing a zipper:+-----ROOT[1]----+ | | +----A[2]---+ +---B[3]--+ | | | | | | X[4] Y[5] Z[6] J[7] H[8] K[9]
This is useful to solve some special tasks related to zippers.
Lookups
When working with zippers, you often need such functionality as “go up/left/right until meet something”. For example, from a given location, go up until a parent has a special attribute. Zippo offers four functions for that, namely
lookup-up
,lookup-left
,lookup-right
, andlookup-down.
All of them take a location and a predicate:(let [loc (zip/vector-zip [:a [:b [:c [:d]]] :e]) loc-d (zippo/loc-find loc (zippo/->loc-pred (fn [node] (= node :d)))) loc-b (zippo/lookup-up loc-d (zippo/->loc-pred (fn [node] (and (vector? node) (= :b (first node))))))] (is (= :d (zip/node loc-d))) (is (= [:b [:c [:d]]] (zip/node loc-b))))
In the example above, first we find the
:d
location. From there, we go up until we meet[:b [:c [:d]]]
. If there is no such a location, the result will be nil.Also See
The code from this library was used for Clojure Zippers manual – the complete guide to zippers in Clojure from the very scratch.
© 2022 Ivan Grishaev
- Layer 1 is
-
Деформация
Последний месяц я вожусь с ботами для Телеграма. Занятная вещь, и как-нибудь расскажу об этом подробней. А пока что мелкое наблюдение.
Чтобы отсечь спамеров, мой бот выводит задачу уровня
3 + 5
и кнопки с ответами. Кнопок шесть: одна правильная, остальные неправильные. Писать текст нельзя, только жать на кнопку. Если ответил верно, ограничения снимаются. После трех ошибок бан.Код для генерации кнопок я писал впопыхах. Коротко, он такой: взять пять случайных чисел, добавить к ним ответ, перемешать. Но не учел, что в случайных числах может выпасть ответ, из-за чего будет две правильные кнопки. Это случается редко, но все же. Вот один из случаев:
Так вот, наблюдение: когда айтишник видит два правильных ответа, у него рвется шаблон. Начинаются фразочки OMG какой ответ правильный!! и прочая истерика. Тут у меня происходит двоемыслие.
С одной стороны, что значит “какой ответ правильный”? Если задача
3 + 5
и две кнопки 8, обе из них правильные. Не бывает двух разных цифр 8 — она одна. Поэтому вместо истерики надо жать ту кнопку, которая по душе — ближе, дальше, круглее, — главное, чтобы на ней была восьмерка.С другой стороны, айтишников легко понять. Мы привыкли, что за текстом на кнопке стоит айдишник, который уходит на сервер, и все рассчитывается по айдишнику. Вполне возможно, у первой кнопки “8” айди
btn_4
, а у второй —btn_9
. И не докажешь, что не верблюд.Помню, меня страшно бесили тесты по английскому на компе. Нужно было перетащить слова в прочерки, при этом глагол is часто встречался дважды. И хотя я все расставлял правильно, получал ошибку. Оказалось, у каждого слова свой айдишник, а в базе данных задана их последовательность. Имеем два is с номерами 2 и 5. Не так расставил — ошибка.
В итоге скрипт, написанный на коленке, разошелся по школам, сайтам, университетам. Где только я с ним не сталкивался! Даже недавно, во время ковидного дурдома, сын делал английский онлайн, и там была та же проблема.
Происходит деформация: даже если на двух кнопках написано одно и то же, мы не верим. Мы ищем подвох, потому что слишком часто были обмануты. В результате две кнопки 8 кажутся такой же ошибкой, как 3 + 5 = 9.
Сюда же следует отнести штучки JavaScript вроде
[1, 2, 3] == [1, 2, 3]
. Только что проверил в консоли — вернет ложь. На JavaScript работают миллионы сайтов и устройств, а конца этому не видно. Весь этот бред так и тянется от стандарта к стандарту.В результате разработчик настолько привык к граблям технологий, что всячески оправдывает их косяки и ищет злой умысел там, где его нет. Так быть не должно.
Подобно тому, как нужно не курить, не бухать и заниматься спортом, нужно работать с правильными технологиями. Такими, где массив
[1, 2, 3]
все-таки равен другому такому же массиву; где нет пяти функций сравнения; где код вернет то, что ожидаешь, а не то, что прописано на сотой странице стандарта. И деформации в голове станет меньше. -
REPL, Cider, Emacs (часть 2/4)
Все части
Оглавление
- REPL в редакторе
- Знакомство с nREPL
- Подключение из Clojure
- Клиенты nREPL для редакторов
- Emacs и Cider
REPL в редакторе
До сих пор мы набирали код в терминале, что не совсем удобно. Терминал подходит для коротких команд, но плохо справляется с многострочным вводом. Будет правильно набрать код в редакторе, а затем скопировать в терминал. Код останется в файле, и не придется печатать его в следующий раз.
Со временем вы заметите, что переключение между редактором и терминалом отнимает время. Было бы здорово связать редактор с REPL напрямую. Вы набираете код и с помощью комбинации клавиш выполняете в REPL. В отдельной области редактор показывает результат. С таким подходом нам доступна мощь обеих сред: REPL и редактора.
-
Веб-файлы
Каждый раз, когда вижу файловый интерфейс в вебе, думаю: почему такой пиздец? Я не люблю материться и делаю это редко. Но когда вижу браузерные поделки, хочется закрыть глаза от отчаяния.
Вот скриншоты с официального сайта Дропбокса. Первая картинка:
Что мы видим? Всюду адская разреженность. Изображение с отступами, сверху и справа два жирных бара. Иконки в барах мелкие и висят в воздухе. Дикая несогласованность элементов: сперва кнопка с надписью, кнопка с многоточием, заголовок, синяя кнопка со стрелкой вниз.
Что делает заголовок среди кнопок? Там не поместится ничего длиннее пары слов. Почему кнопка Share синяя, в чем ее особый статус? Почему выпадашка слева отмечена многоточием, а у Share стрелка вниз?
Зачем колокольчик в окне просмотра файла? Это события, связанные с этим файлом или любые события? Зачем сайдбар справа? Нельзя было уместить жалкие три кнопки наверху?
Другая картинка:
То же самое: адская разреженность. На месте трех файлов было легко уместить пятнадцать без потери смысла и качества.
Наверху значок лупы, но поля ввода нет. Полагаю, оно появится по клику на лупу. Зачем кликать, если можно сразу нарисовать поле?
Зачем земной шар? Открыть какую-то ссылку? Если да, то какую, и зачем мне туда переходить? Почему бы не разместить гиперссылку с текстом, например, “на сайт”?
Полоса с текстом “Syncing” срезает столько места, что хватило бы на пару файлов.
Третья картинка, но там уже нечего комментировать: сочетание косяков в разных пропорциях.
А вот картинка, открытая в Preview на маке:
- заголовок отдельно от кнопок. Влезет даже предложение.
- изображение занимает максимальную площадь
- виджет поиска отрисован с полем ввода
- все иконки одинаковы и пригнаны друг к другу
- по желанию в тулбар можно загнать другие иконки и поменять их местами
- можно сменить показ иконок: только картинка, только текст или вместе.
Дело даже не в маке: если взять Total Commander или виндовый Explorer, там то же самое: информация показана компактно и удобно. Могут быть придирки к конкретной программе: скажем, Explorer в режиме значков такой себе, но со списком можно жить. Это уже дело вкуса. Важно, что всем файловым менеджерам свойственна компактность и гибкость в настройках.
А с вебом беда. Мамкины дизайнеры каждый день видят нормальный интерфейс, но рисуют в Фигме говно. Хотя ничего придумывать не надо: повтори Finder или Explorer, и люди скажут спасибо. Но нет: дизайнер дизайнерит.
Беда не только у Дропбокса. Гугл, Слак и другие тяжеловесы не могут показать файлы в браузере. Каждый раз выходит ужас: растрата места, жирные бары, несогласованные кнопки — тут многоточие, там выпадашка. Иконки либо мелкие, либо гротескно жирные, словно в азбуке для малышей. Ни у одной иконки нет подписи, нужно наводить курсор или жать наудачу.
Невозможно. Считаю, в целях воспитания нужно отобрать у дизайнеров родной файловый менеджер и выдать то, что они производят. Тогда дело быстро пойдет на лад.
-
REPL, Cider, Emacs (часть 1/4)
Все части
Оглавление
Эта глава расскажет о REPL — фундаментальном свойстве Clojure. Так называют интерактивную работу с языком, когда программу наращивают постепенно. Мы рассмотрим, что такое REPL-driven development и почему, однажды познав, от него трудно отказаться.
Аббревиатура REPL происходит от четырех слов: Read, Eval, Print и Loop. Дословно они означают прочитать, выполнить, напечатать и повторить. REPL — устойчивый термин, под которым понимают интерактивный режим программы.
Многие современные языки предлагают интерактивный режим. Как правило, он запускается, если вызвать интерпретатор без параметров. Например, команды
python
илиnode
запустят интерактивные сеансы Python и Node.js. В Ruby для этого служит утилитаirb
(где i означает interactive). REPL поддерживают не только интерпретаторы, но и языки, которые компилируются в байт-код (Java, Scala) или машинный код (Haskell, SBCL).Несмотря на это разнообразие, именно в Лисп-системах REPL имеет решающее значение. Если в Python или Node.js его рассматривают как приятное дополнение, то в Лиспе он необходим. Разработка на любом Лиспе зависит от того, насколько хорошо вы взаимодействуете с REPL. На REPL так или иначе опираются все инструменты и практики, документация, видеоуроки и так далее.
В мире Лиспа ходит понятие REPL-driven development. Это стиль разработки, когда код пишут малыми порциями и запускают в REPL. С таким подходом сразу видно поведение программы. Легче проверить неочевидные случаи, например вызвать функцию с
nil
или обратиться к ресурсу, которого не существует.Другой полезный сценарий для REPL — извлечь данные из сети и исследовать их. Эта задача идеально ложиться на интерактивный режим. Как правило, HTTP-запрос предполагает несколько этапов: его подготовку, отправку, чтение тела и поиск нужных полей. Эти шаги проходят интерактивно методом проб и ошибок. Позже мы рассмотрим пример HTTP-запроса в сеансе REPL.
-
Teleward: a CAPTCHA bot for Telegram in Clojure + GraalVM
(This is a copy of the readme file from the repository.)
Teleward is a Telegram captcha bot written in Clojure and compiled with GraalVM native image. Runs on bare Linux/MacOS with no requirements. Fast and robust.
Why
Telegram chats suffer from spammers who are pretty smart nowadays. They don’t use bots; instead, they register ordinary accounts and automate them with Selenium + web-version of Telegram. Personally, I found Shieldy and other bots useless when dealing with such kind of spammers. This project aims the goal to finish that mess.
Another reason I opened Teleward for is to try my skills in developing Clojure applications with GraalVM. Binary applications are nice: they are fast, and they don’t need installing JDK. At the same time, they’re are still Clojure: REPL is here, and that’s amazing.
Features
- This is Clojure, so you have REPL! During development, you call Telegram API directly from REPL and see what’s going on.
- The bot can be delivered either as a Jar file or a binary file (with Graal).
- When Graal-compiled, needs no requirements (Java SDK, etc). The binary size is about 30 Mb.
- At the moment, supports only long polling strategy to obtain messages. The webhook is to be done soon.
- Keeps all the state in memory and thus doesn’t need any kind of a database. The only exception is the current offset value which is tracked in a file.
- Supports English and Russian languages.
- Two captcha styles: normal “1 + 2” and Lisp captcha “(+ 1 2)”.
- The
+
,-
, and*
operators are corresponding Unicode characters that prevent captcha from naive evaluation.
Algorithm
The bot listens for all the messages in a group. Once a new pack of messages arrives, the bot applies the following procedure to each message:
- Mark new members as locked.
- Send a captcha message to all members.
- Unless an author of a message is locked, delete that message.
- If a message is short and matches the captcha’s solution, unlock a user and delete the catpcha message.
- If a locked user has posted three messages with no solution, ban them.
- If a locked user hasn’t solved captcha in time, ban them as well.
Please note: the bot processes only messages no older than two minutes from now. In other words, the bot is interested in what is happening now (with a slight delay), but not in the far past. This is to prevent a sutuation what a bot had been inactive and then has started to consume messages. Without this condition, it will send captcha to chat members who have already joined and confuse them.
Java version
To make a Jar artefact, run:
make uberjar
The
uberjar
target callslein uberjar
and also injects theVERSION
file into it. The output file is./target/teleward.jar
.Binary version, Linux
Linux version is built inside a Docker image, namely the
ghcr.io/graalvm/graalvm-ce
one withnative-image
extension preinstalled. Run the following command:make docker-build
The output binary file appears at
./target/teleward
.Binary version, MacOS
-
Install GraalVM locally.
-
Install the “native image” extension:
gu install native-image
- Then
make
the project:
make
Setting Up Your Bot
-
To run the bot, first you need a token. Contact
@BotFather
in Telegram to create a new bot. Copy the token and don’t share it. -
Add your new bot into a Telegram group. Promote it to admins. At least the bot must be able to 1) send messages, 2) delete messages, and 3) ban users.
-
Run the bot locally:
teleward -t <telegram-token> -l debug
If everything is fine, the bot will start consuming messages and print them in console.
Configuration
See the version with
-v
, and help with-h
. The bot takes into account plenty of settings, yet not all of them are available for configuration for now. Below, we name the most important parameters you will need.-
-t, --telegram.token
: the Telegram token you obtain from BotFather. Required, can be set via an env variableTELEGRAM_TOKEN
. -
-l, --logging.level
: the logging level. Can bedebug, info, error
. Default isinfo
. In production, most likely you will seterror
. -
--telegram.offset-file
: where to store offset number for the nextgetUpdates
call. Default isTELEGRAM_OFFSET
in the current working directory. -
--lang
: the language for messages. Can been, ru
, default isru
. -
--captcha.style
: a type of captcha. Whenlisp
, the captcha looks like a lisp expression(+ 4 3)
. Any other value type will produce4 + 3
. The operator is taken randomly.
Example:
./target/teleward -t <...> -l debug \ --lang=en --telegram.offset-file=mybot.offset \ --captcha.style=normal
For the rest of the config, see the
src/teleward/config.clj
file.Deploying on bare Ubuntu
-
Buy the cheapest VPS machine and SSH to it.
-
Create a user:
sudo useradd -s /bin/bash -d /home/ivan/ -m -G sudo ivan sudo passwd ivan mkdir /home/ivan/teleward
- Compile the file locally and copy it to the machine:
scp ./target/teleward ivan@hostname:/home/ivan/teleward/
- Create a new
systemctl
service:
sudo mcedit /etc/systemd/system/teleward.service
- Paste the following config:
[Unit] Description = Teleward bot After = network.target [Service] Type = simple Restart = always RestartSec = 1 User = ivan WorkingDirectory = /home/ivan/teleward/ ExecStart = /home/ivan/teleward/teleward -l debug Environment = TELEGRAM_TOKEN=xxxxxxxxxxxxxx [Install] WantedBy = multi-user.target
- Enable autoload:
sudo systemctl enable teleward
- Manage the service with commands:
sudo systemctl stop teleward sudo systemctl start teleward sudo systemctl status teleward
For Jar, the config file would be almost the same except the
ExecStart
section. There, you specify something likejava -jar teleward.jar ...
.Health check
The bot accepts the
/health
command which it replies to “OK”.Further work
- Implement webhook.
- Add tests.
- Report uptime for
/health
. - More config parameters via CLI args.
- Widnows build.
© 2022 Ivan Grishaev
-
Невышедший подкаст про Лисп. Работа над ошибками
История о том, как я участвовал в подкасте про Лисп и Кложу, который не вышел в свет. Это ни в коем случае не обвинения любой из сторон, а попытка понять, что пошло не так. Кроме того, я вынес кое-какие уроки, чтобы подобное не повторилось в будущем.
Три месяца назад мне постучались одни Крутые Ребята с предложением выступить в подкасте. Тема — Лисп и Кложа. Назначили дату и время, записали подкаст. Еще до записи кое-что показалось мне странными, но я утешал себя тем, что ребята Крутые и знают, что делают. Поэтому оставил на их усмотрение.
Итак, записали, обменялись любезностями, супер-супер, огонь-огонь. Проходит месяц, другой. Ребята выпускают эпизоды, но моего среди них нет. Я спокойно жду, потому что знаю: у опытных подкастеров эпизоды расписаны на месяц. Кроме того, интересный материал иногда оставляют “в консервах”, то есть на случай, когда ничего интересного нет.
В какой-то момент не стерпел и вежливо спросил, как дела с выпуском. Оба собеседника получили сообщение и… тишина. Проходит семь часов, я аккуратно повторяю вопрос. Наконец отвечают: выпуска не будет. Слово за словом выясняется, что ведущий им недоволен, получилось очень сложно, слушатели не поймут.
Что ж, не будет и бог с ним, бывает. Но удивляюсь, почему нельзя было сказать об этом раньше вместо того, чтобы прятаться? Ведь Ребята такие Крутые: открытые, позитивные, вот это все. Но как только пошло не так, началась тишина.
Безо всяких претензий пишу им: понимаю, не в обиде. Можно выложу у себя в блоге? Для этого нужна дорожка ведущего: во время записи у меня осталась только моя дорожка. Сведу и смонтирую сам. Отвечают: нужно согласие Главного Человека, сейчас спросим его и ответим. Я аж поморщился от этой лжи, потому что у тех, с кем я разговаривал, было достаточно полномочий для принятия решения. Они всем управляют, и вдруг оказывается, нужно спросить. Стоит ли говорить, что через месяц мне никто не ответил.
По итогам событий я сделал следующие выводы.
(1) Перед выступлением нужно изучить аудиторию, понять ее уровень. Уже после согласия я понял, что это популярный подкаст по принципу “все обо всем”. Рассказывать такой аудитории про Кложу и Лисп трудно. Это специфичные технологии, и слушатель должен знать азы программирования. Если этого нет, рассказ про Лисп не принесет удовольствия ни одной из сторон, что и подтвердилось. Во время записи ведущий часто перебивал меня с просьбой объяснить тот или иной момент. Я, напротив, был недоволен, что мы топчемся на месте. К концу второго часа мы наконец выяснили, что код на Лиспе состоит из списков, и в нем есть REPL и макросы. Это десятая часть того, что я мог бы рассказать про Лисп подготовленной аудитории.
(2) Стороны должны согласовать тезисы. Когда я выступал в Подлодке, ведущий потребовал с меня тезисы и прислал образец с прошлого выпуска. Это в высшей степени правильно, потому что делает выпуск согласованным: гость и ведущий на одной волне. В случае с Крутыми Ребятами тезисы не понадобились: ведущий сказал, что подготовится сам. Позже он “ради интереса” попросил выслать тезисы для Подлодки, что я и сделал. Однако ни один тезис мы не обсудили.
(3) Если что-то идет не так, надо честно признаться и сказать правду, не дожидаясь разборок. Меня расстроило не то, что эпизод не вышел, а отношение по принципу “молчим, авось отъебется”. Не надо так, потому что со страниц блогов и проектов вы Крутые, и вдруг такое. Реально расстраиваешься.
(4) В то же время, это урок мне. Вдруг я тоже произвожу хорошее впечатление в блоге, а на деле сливаюсь? Не обидел ли я кого нибудь таким же образом? Пишу одно, делаю другое? Есть над чем подумать.
В завершение текста покажу одно видео. Выступает Илья Синельников, тема “Провал — это хорошо”. Одно из немногих видео, которое я однажды посмотрел и тепло вспоминаю. Если коротко — важно не то, как вы ведете себя, когда все хорошо. Истинно важно — когда дела идут не по плану; тут и проявляется самое важное. Полчаса удовольствия:
-
Сервис не смог
Иные сервисы уведомляют о всякой ерунде — новом дизайне, посте в блоге или просто какой-то дичи — и забывают о важных вещах. Например, проблемах с платежами и авторизацией. Вот три примера из практики.
До известных событий (которые в России нельзя назвать коротким словом) сервис Gumroad регулярно переводил деньги за книгу на Paypal. Как только Палка отвалилась, пропали и выплаты. Однако Gumroad не прислал ни одного письма об этом.
Как программист я понимаю: вызов к PayPal обернут в try/catch, ошибка падает в лог. Ладно, а что потом? Получили сотни ошибок, кто будет с ними разбираться?
По-хорошему надо так: делать задачу на выплату, повторять ее с интервалом в час, день, неделю. Если не вышло — слать пользователю письмо, а не молчать, словно ничего не происходит.
То же самое с Leanpub. У меня там короткая книжка на английском, и раз-два в месяц ее покупают. Выплаты прекратились, все молчком, никаких уведомлений. Неужели так трудно отправить письмо?
Наконец, Амазон. Какое-то время назад я жаловался, что не могу зайти в аккаунт. Думал, что блокирует Амазон, но все оказалось проще. Когда-то давно, подключая двухфакторную авторизацию, я поленился открыть Google Authenticator и выбрал вариант с смс — зря!
Оказалось, сервис, который отправляет смс, теперь не делает этого для российских номеров. Без понятния, как это происходит: возвращает ли он статус 403? Или отдает 200, чтобы молча выкинуть мое смс? И кто виноват? Я, потому что живу в России? Амазон, потому что сервис не работает, а к нему обращаются? Сервис, потому что не смог?
Кто кого должен уведомлять? Амазон меня, потому что дело происходит на странице Амазона? Или смс-сервис уведомляет Амазон, потому что первый не смог? Все сломано: смс не доходит, Амазон об этом не знает, сервис не считает нужным доложить. Скорей всего, записали в лог и забыли.
Как вы поняли, так быть не должно. Клиенту все равно, какие сервисы вызывает ваш сервис. Чей домен открыт в браузере, тот и уведомляет. Во всех случаях письма должны были прислать Gumroad, Leanpub и Amazon — то, что я вижу и с чем работаю. Не нужно переводить стрелки на сторонний сервис, который не смог. В данном случае не смогли упомянутые фирмы.
Все это я переживу, а вот взять на заметку стоит.
-
Use format rather than str
I’d like to share a Clojure trick I’ve been using for years. It’s simple, it makes your code safer; I always highlight it when making reviews. The trick is: don’t use the
str
function to concatenate strings. Instead, useformat
. A couple of examples:(let [username "Ivan"] (str "The user `" username "` has been created.")) ;; "The user `Ivan` has been created." (let [user-id 5234] (str "/files/uploads/" user-id)) ;; "/files/uploads/5234"
The first point in favour of this approach is,
str
turns nil into an empty string. Thus, when printing the final message, that’s unclear if a variable was an empty string ornil
:(let [username nil] (str "The user `" username "` has been created.")) ;; "The user `` has been created."
The difference between these two is important. Say, an empty string means broken validation; a title of a book, a name of a person must not be blank. But if I got nil, most likey I missed the key in a map because of a namespace:
(def user {:acme.user/id 5234 :acme.user/name "Ivan"}) (let [username (get user :id)] ...)
or the keyword/string case:
(def user (parse-json "user.json")) ;; {"id" 5234 "name" "Ivan"} (let [username (get user :name)] ...)
Now compare it to the
format
function. Thenil
value becomes"null"
when passed toformat
:(let [username nil] (format "The user `%s` has been created." username)) ;; "The user `null` has been created." (let [user-id nil] (format "/files/uploads/%s" user-id)) ;; "/files/uploads/null"
If I had my way, I would produce not
"null"
but"nil"
string fromnil
, but that’s not so important.The second point is much more serious.
Nil
values are extremely dangerous when building file paths or URLs. Imagine you’re about to delete files of a person who’s terminating their account. Most likely you store files on disk like this:files/<user-id>/avatars/... files/<user-id>/attachments/... files/<user-id>/uploads/...
Then you have a function that accepts the
user-id
parameter, then builds the right path and does recursive deletion:(defn drop-user-files [user-id] (let [path (str "files/" user-id)] (rm-rf-recur path)))
If you pass
nil
foruser-id
, the path will be"files/"
. Running that code will erase all the files of all users which would be a disaster. But if you have used format, the path would have been"files/null"
, which would just have thrown an exception saying there is no such a directory.One may say: add an assert clause for user-id right before you build a path. Like this:
(defn drop-user-files [user-id] (assert user-id "User-id is empty!") ...) ;; or (defn drop-user-files [user-id] {:pre [user-id]} ...)
In practice, you easily forget doing this and recall when the data is lost. I don’t see any reason for skipping that minor fix — change
str
toformat
— to reduce chances of a disaster.The same applies to S3 URLs. Although it’s a web-service, we all treat it as a file system. Composing S3 URLs reminds me of ordinary file paths. Again, if you’re about to drop user’s directory with uploads, be aware of the the same thing:
str
+nil
foruser-id
produce a broken path:(defn drop-s3-user-files [s3-client user-id] (let [path (str "files/" user-id)] (s3.client/delete-files s3-client path)))
If you pass
nil
into a function that recursively drops S3 files, the data is all gone.Of course, such an issue can be held with special Java classes like
Path
orURI
. But in fact, in Clojure we use them quite rarely. Most often we just concatenate plain strings as it’s enough for the task. It’s simpler and takes less code.I recommend using
str
for one purpose only — to coerce a non-string value to a string. For example:(str 5) ;; => "5" (str (random-uuid)) ;; => "154ac...b2642"
Briefly, it’s safe when the
str
function accepts strictly one argument. When there are more than one, I feel worried.I always keep in mind a real story about a guy who owned a small hosting company. He released the
rm -rf $FOO/$BAR
command among the whole park. Too sad for him, both of the env vars were unset, nor special bash flags terminating a script were set as well. The command turned intorm -rf /
with known consequences. A couple of missing vars has ruined one’s business. Bystr
-ing strings, especially when building file paths, you may easily mess up the same way (but I wish you won’t).Let’s recap:
str
turnsnil
into an empty string;- by reading such a message, you never know if a value was an empty string or nil;
- the difference between these two does matter;
- with that behaviour, it’s easy to build a weird file/S3 path and lost the data;
- instead,
format
turnsnil
into"null"
. This is much better than emptiness and solves the troubles mentioned above; - use
str
only to coerce a value to a string.
Safe coding!