• 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 of loc-pred and loc-fn, it accepts node-pred and node-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, and lookup-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

  • Деформация

    Последний месяц я вожусь с ботами для Телеграма. Занятная вещь, и как-нибудь расскажу об этом подробней. А пока что мелкое наблюдение.

    Чтобы отсечь спамеров, мой бот выводит задачу уровня 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 в редакторе

    До сих пор мы набирали код в терминале, что не совсем удобно. Терминал подходит для коротких команд, но плохо справляется с многострочным вводом. Будет правильно набрать код в редакторе, а затем скопировать в терминал. Код останется в файле, и не придется печатать его в следующий раз.

    Со временем вы заметите, что переключение между редактором и терминалом отнимает время. Было бы здорово связать редактор с REPL напрямую. Вы набираете код и с помощью комбинации клавиш выполняете в REPL. В отдельной области редактор показывает результат. С таким подходом нам доступна мощь обеих сред: REPL и редактора.

    Read more →

  • Веб-файлы

    Каждый раз, когда вижу файловый интерфейс в вебе, думаю: почему такой пиздец? Я не люблю материться и делаю это редко. Но когда вижу браузерные поделки, хочется закрыть глаза от отчаяния.

    Вот скриншоты с официального сайта Дропбокса. Первая картинка:

    Что мы видим? Всюду адская разреженность. Изображение с отступами, сверху и справа два жирных бара. Иконки в барах мелкие и висят в воздухе. Дикая несогласованность элементов: сперва кнопка с надписью, кнопка с многоточием, заголовок, синяя кнопка со стрелкой вниз.

    Что делает заголовок среди кнопок? Там не поместится ничего длиннее пары слов. Почему кнопка 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.

    Read more →

  • 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 calls lein uberjar and also injects the VERSION 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 with native-image extension preinstalled. Run the following command:

    make docker-build
    

    The output binary file appears at ./target/teleward.

    Binary version, MacOS

    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 variable TELEGRAM_TOKEN.

    • -l, --logging.level: the logging level. Can be debug, info, error. Default is info. In production, most likely you will set error.

    • --telegram.offset-file: where to store offset number for the next getUpdates call. Default is TELEGRAM_OFFSET in the current working directory.

    • --lang: the language for messages. Can be en, ru, default is ru.

    • --captcha.style: a type of captcha. When lisp, the captcha looks like a lisp expression (+ 4 3). Any other value type will produce 4 + 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 like java -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, use format. 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 or nil:

    (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. The nil value becomes "null" when passed to format:

    (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 from nil, 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 for user-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 to format — 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 for user-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 or URI. 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 into rm -rf / with known consequences. A couple of missing vars has ruined one’s business. By str-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 turns nil 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 turns nil 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!

  • Книга на Доброфайле

    Сервис Gumroad, где продается электронная версия книги, ожидаемо перестал работать с российскими картами (а также остановил выплаты). Поэтому выкладываю книжку на российской площадке “Доброфайл”:

    https://dobrofile.ru/?s=c2f86cd04

    Все просто: переходите по ссылке, жмете “купить файл”. Никакой авторизации: ввели емейл и оплатили картой. На почту падает ссылка, которая действует 48 часов. Только что купил сам — работает.

    Сервис довольно забавный: простой как лопата, весь такой спартанский. После тормозного, обвешанного скриптами Gumroad воспринимается как глоток воздуха. Админка товара открывается секунду — так можно было?

    В общем, покупайте книжку на Доброфайле. В ближайшее время обновлю страницу проекта, публикацию на Хабре и другие места.

    Напомню, что книжку, как печатную, так и электронную, всегда можно купить напрямую у меня. Напишите в личку, и я скину архив или вышлю почтой России в любую страну.

    А еще я почти закончил черновик второй части, но об этом в следующий раз.

Страница 3 из 60