• Веб-файлы

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

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

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

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

    Исторический экскурс

    REPL отсчитывает свою историю от первых Лисп-машин. Это были мейнфреймы с запущенным на них интерпретатором Лиспа. Подобные машины использовали в Xerox для печати, обработки изображений, управления оборудованием, решения задач, связанных с машинным обучением и искусственным интеллектом. Разработку Лисп-машин поддерживал отдел DARPA, в том числе потому, что их применяли в военной отрасли.

    Золотой век Лисп-машин пришелся на период с 1977 по 1985 год, после чего последовал спад их популярности. Из-за особенностей архитектуры они не могли конкурировать с процессором x86 и компилируемыми языками, — как в плане цены, так и производительности. В итоге Лисп-машины полностью ушли с рынка, но подход REPL, придуманный полвека назад, навсегда остался в индустрии.

    Подход и вправду был инновационным. До него программу набирали в редакторе, компилировали и только потом запускали (для краткости опустим этап с перфокартами). Процесс был долгим и дорогим. Наоборот, Лисп-машина принимала код и выполняла его мгновенно. Для своего времени Лисп был очень высокоуровневым языком. На нем легко выразить сложную логику, не отвлекаясь на низкоуровневые проблемы. Именно в Лиспе появился автоматический контроль за памятью и сборка мусора. Все это делало Лисп-машину идеальной площадкой для экспериментов.

    Интерпретатор Лисп-машины был не просто программой по запуску кода. Фактически он был ее операционной системой, потому что имел доступ к регистрам процессора, оперативной памяти и устройствам ввода-вывода. В REPL можно было просмотреть все переменные и функции, переопределить и удалить их. Это свойство — полный контроль системой — тоже стало неотъемлемой частью REPL.

    Язык Clojure, как и другие диалекты Лиспа, активно поддерживает REPL и все связанное с ним. Без знания REPL работа с Clojure будет неэффективна. Классический подход, когда сначала вы пишете программу, а потом запускаете, здесь не работает. Цель этой главы — показать практическую, REPL-ориентированную разработку, принятую в Clojure.

    Пробуем REPL

    Чтобы познакомиться с REPL, запустим его. Это можно сделать несколькими способами.

    Первый — установить утилиту Leiningen для управления проектами на Clojure. Инструкции по установке вы найдете на официальном сайте. Когда утилита установлена, выполните в терминале:

    > lein repl
    

    Второй способ — установить набор утилит Clojure CLI. На официальном сайте Clojure представлены команды установки для Linux и MacOS. После установки в системе появятся команды clojure и clj для запуска проекта. Если вызвать любую из них, запустится REPL:

    > clj
    > clojure
    

    Старые версии Clojure (до 1.8 включительно) можно запустить напрямую из jar-файла командой java:

    > java -jar clojure-1.8.0.jar
    

    или явно указав класс clojure.main:

    > java -cp clojure-1.8.0.jar clojure.main
    

    С версии 1.9 Clojure состоит из нескольких jar-файлов. Библиотека clojure.spec, которую мы рассмотрели в первой книге, поставляется отдельно. Скачайте jar-файлы из репозитория Maven в разделах org.clojure/clojure и org.clojure/spec.alpha. Далее выполните команду:

    > java -cp clojure-1.11.1.jar:spec.alpha-0.3.218.jar clojure.main
    

    Когда REPL запущен, вы увидите приглашение:

    user=>
    

    Слово user означает текущее пространство имен. Если не задано иное, REPL запускается в пространстве user. Позже мы узнаем, как задать другое пространство или переключить его.

    Введите любое выражения на Clojure: число, строку в двойных кавычках или кейворд. Эти значения вычисляются сами в себя:

    user=> 1
    1
    
    user=> :test
    :test
    
    user=> "Hello REPL!"
    "Hello REPL!"
    

    Задайте глобальную переменную:

    user=> (def amount 3)
    #'user/amount
    

    и сошлитесь на нее в выражении:

    user=> (+ amount 4)
    7
    

    Более сложный пример. Определите функцию add, которая складывает два числа. Введите ее в одну строку:

    user=> (defn add [a b] (+ a b))
    #'user/add
    

    и проверьте вызов:

    user=> (add 5 -4)
    1
    

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

    (defn add [a b]
      (+ a b))
    

    Если напечатать (defn add [a b] и нажать ввод, по незакрытой скобке REPL определит, что выражение неполное. Ошибки не произойдет, и следующая строка дополнит исходную. Как только скобки станут сбалансированы, REPL выполнит форму.

    user=> (defn add [a b]
      #_=> (+ a b))
    #'user/add
    

    Подключите любой из модулей Clojure, например clojure.string для работы со строками:

    user=> (require '[clojure.string :as str])
    nil
    

    С его помощью разбейте строку или выполните автозамену:

    user=> (str/split "one two three" #"\s+")
    ["one" "two" "three"]
    
    user=> (str/replace "Two minutes, Turkish!", #"Two" "Five")
    "Five minutes, Turkish!"
    

    Модуль clojure.inspector предлагает примитивный графический отладчик. Его функция inspect-tree принимает структуру данных и выводит окно с деревом папок. Значок папки означает коллекцию; если его раскрыть, появятся дочерние элементы с иконками файлов.

    Чтобы изучить переменные среды, выполните:

    (require '[clojure.inspector :as insp])
    (insp/inspect-tree (System/getenv))
    

    Содержимое окна будет примерно таким:

    {}
    └─ JAVA_MAIN_CLASS_68934=clojure.main
    └─ LC_TERMINAL=iTerm2
    └─ COLORTERM=truecolor
    └─ LOGNAME=ivan
    └─ TERM_PROGRAM_VERSION=3.3.12
    └─ PWD=/Users/ivan/work/book-sessions
    └─ SHELL=/bin/zsh
    

    Опробуйте случай с ошибкой. Поделите число на ноль или сложите число с nil. REPL не завершится, но выведет исключение на экран:

    user=> (/ 1 0)
    Execution error (ArithmeticException) at user/eval554...
    Divide by zero
    

    Это правильное поведение: в процессе работы ошибки случаются часто, и нам бы не хотелось завершать JVM. Однако это справедливо только для сеанса REPL. В боевом запуске программы на Clojure ведут себя как обычно: если исключение не поймано, программа завершается.

    По умолчанию REPL выводит краткое сообщение об ошибке. Объект исключения остается в переменной *e. Исследуем ее:

    user=> *e
    
    #error {
     :cause "Divide by zero"
     :via
     [{:type java.lang.ArithmeticException
       :message "Divide by zero"
       :at [clojure.lang.Numbers divide "Numbers.java" 188]}]
     :trace
     [[clojure.lang.Numbers divide "Numbers.java" 188]
      [clojure.lang.Numbers divide "Numbers.java" 3901]
      [user$eval8888 invokeStatic "form-init3207458389100610076.clj" 1]
      [user$eval8888 invoke "form-init3207458389100610076.clj" 1]
      ...
      [nrepl.middleware.session$session_exec$main_loop__1048 invoke "session.clj" 201]
      [clojure.lang.AFn run "AFn.java" 22]
      [java.lang.Thread run "Thread.java" 829]]}
    

    В первой книге мы рассмотрели, что можно сделать с исключением: напечатать в удобном виде, записать в лог, отправить в систему сборки ошибок.

    Когда REPL что-то вычислил, результат остается в переменной *1. С ней легко избежать повторных вычислений. Предположим, мы ввели код, который дает объемный результат:

    (into {} (System/getenv))
    
    {"HOME" "/Users/ivan"
     "LC_TERMINAL_VERSION" "3.3.12"
     "USER" "ivan"
     ...}
    

    Чтобы сослаться на результат, не вычисляя повторно, введите:

    (get *1 "USER")
    ;; "ivan"
    

    Переменная *1 полезна для записи в файл. Предположим, мы хотим сохранить результат, чтобы исследовать позже. Для этого введите:

    (spit "dump.edn" (pr-str *1))
    

    На диске появится файл dump.edn с данными. Позже мы прочтем их комбинацией slurp и read-string:

    user=> (read-string (slurp "dump.edn"))
    
    {"HOME" "/Users/ivan"
     "LC_TERMINAL_VERSION" "3.3.12"
     "USER" "ivan"
     ...}
    

    Вам доступны три переменных результата: *1, *2 и *3. С каждым вычислением результаты смещаются: последний будет в *1, предпоследний в *2 так далее. Покажем это на примере:

    user=> 1
    1
    
    user=> 2
    2
    
    user=> 3
    3
    
    user=> (println *1 *2 *3)
    3 2 1
    

    Чтобы загрузить в REPL несколько определений, используйте функцию load-file. Она принимает один аргумент — путь к файлу с кодом на Clojure:

    (load-file "my_functions.clj")
    

    Эффект аналогичен тому, как если бы вы скопировали содержимое файла и вставили в REPL. Функция не влияет на текущее пространство имен, как это делают формы ns или require. В боевом коде load-file не используют, потому что такая загрузка делает код неочевидным: неясно, откуда взялась та или иная переменная. Однако для экспериментов load-file подходит идеально.

    REPL предлагает макросы для интроинспеции. Выражение (doc ...) напечатает справку к указанной функции, например:

    (doc assoc)
    -------------------------
    clojure.core/assoc
    ([map key val] [map key val & kvs])
      assoc[iate]. When applied to a map, returns a new map of the
        same (hashed/sorted) type, that contains the mapping of key(s) to
        val(s). When applied to a vector, returns a new vector that
        contains val at index. Note - index must be <= (count vector).
    

    А форма (source ...) — ее исходный код (приведем в сокращении):

    (source assoc)
    (def
     ^{:arglists '([map key val] [map key val & kvs])
       :doc "..."
       :added "1.0"
       :static true}
     assoc
     (fn ^:static assoc
       ([map key val] (clojure.lang.RT/assoc map key val))
       ([map key val & kvs]
        (...)))) ;; truncated
    

    Doc и source работают с любыми загруженными модулями. Вот как получить справку по разбиению строк из модуля clojure.string:

    (doc clojure.string/split)
    -------------------------
    clojure.string/split
    ([s re] [s re limit])
      Splits string on a regular expression.  Optional argument limit is
      the maximum number of splits. Not lazy. Returns vector of the splits.
    

    REPL поддерживает и другие возможности, о которых мы поговорим позже. Пока что завершите сеанс нажатием Ctrl+D или выполните (quit) или (exit).

    Более сложный сценарий

    REPL подходит не только для быстрых экспериментов; опытные разработчики проводят в нем часы и дни. Одна из причин этого в том, что REPL — лучший способ разведать ситуацию, когда вы не знаете точно, какие данные приходят из внешних источников.

    Предположим, мы пишем бота для Telegram, который публикует шутки для программистов. Понадобится сервис, который бы выступил в роли источника шуток. Быстрый поиск дает нам сервис Joke API с удобным API по протоколу HTTP.

    Прежде чем писать бота, убедимся в работе сервиса. Для этого понадобятся HTTP-клиент и парсер JSON. Если вы запускаете REPL при помощи lein, создайте файл project.clj с содержимым:

    (defproject repl-chapter "0.1.0-SNAPSHOT"
      :dependencies [[org.clojure/clojure "1.10.0"]
                     [clj-http "3.9.1"]
                     [cheshire "5.8.1"]])
    

    Для утилит Clojure CLI файл deps.edn выглядит так:

    {:deps
     {clj-http/clj-http {:mvn/version "3.9.1"}
      cheshire/cheshire {:mvn/version "5.8.1"}}}
    

    Запустите REPL. Обе библиотеки, если еще не были установлены локально, скачаются на ваш компьютер. Подключите их в сеансе:

    (require '[clj-http.client :as client])
    (require 'cheshire.core)
    

    Подготовим словарь запроса:

    (def request
      {:url "https://v2.jokeapi.dev/joke/Programming"
       :method :get
       :as :json})
    

    Получим ответ:

    (def response
      (client/request request))
    

    Каждое выражение мы связываем с переменной формой def, чтобы позже сослаться на нее. Такой стиль не подходит для промышленного кода, но приемлем в REPL. Поместим тело в отдельную переменную и напечатаем его:

    (def data
      (:body response))
    
    {:category "Programming"
     :delivery "They only like chicken NuGet."
     :type "twopart"
     :setup ".NET developers are picky when it comes to food."
     :lang "en"
     :id 49
     :error false
     :safe true
     :flags
     {:nsfw false
      :religious false
      :political false
      :racist false
      :sexist false
      :explicit false}}
    

    Чтобы лучше понять структуру ответа, исследуем его в инспекторе:

    (require '[clojure.inspector :as insp])
    (insp/inspect-tree data)
    

    Из данных видно, что шутка состоит из двух частей: setup и delivery (термины можно перевести как “подводка” и “разрешение”). Такая структура полезна, когда разрешение показывают не сразу, а после паузы или под тегом спойлера. Так у читателя будет шанс придумать свой вариант. В нашем случае мы просто объединим обе фразы:

    (def joke
      (let [{:keys [setup
                    delivery]} data]
        (format "%s %s" setup delivery)))
    
    ".NET developers are picky when it comes to food. They only like chicken NuGet."
    

    Если передать необязательный параметр contains, получим шутку на заданную тему. Например, если это чат о языке Python, будем шутить про JavaScript:

    (def request
      {:url "https://v2.jokeapi.dev/joke/Programming"
       :method :get
       :query-params {:contains "javascript"}
       :as :json})
    
    ...
    
    "Why was the JavaScript developer sad?
    Because they didn't Node how to Express themself!"
    

    Итак, мы написали фрагменты кода под каждый шаг. Составим функцию, которая принимает язык, про который нужно шутить, и возвращает текст шутки:

    (defn get-joke [lang]
      (let [request
            {:url "https://v2.jokeapi.dev/joke/Programming"
             :method :get
             :query-params {:contains lang}
             :as :json}
    
            response
            (client/request request)
    
            {:keys [body]}
            response
    
            {:keys [setup delivery]}
            body]
    
        (format "%s %s" setup delivery)))
    

    В действии:

    (get-joke "python")
    "Why did the Python programmer not respond to the foreign mails he got?
    Because his interpreter was busy collecting garbage."
    
    (get-joke "javascript")
    "How did you make your friend rage?
    I implemented a greek question mark in his JavaScript code."
    

    На этом этапе еще рано завершать REPL: данные, что мы получили с сервера, пригодятся в тестах. Сохраним их json-файл. Для этого выполним:

    (spit "joke-ok.json"
          (cheshire.core/generate-string
           data {:pretty true}))
    
    ;; or
    
    (require '[clojure.java.io :as io])
    (cheshire.core/generate-stream
      data (io/writer "joke-ok.json") {:pretty true})
    

    Обратите внимание на параметр :pretty. С ним JSON будет красиво оформлен, то есть записан с отступами и переносами строк, а не в одну строку.

    Важно знать, как ведет себя сторонний сервис в случае ошибки. Если пошутить о Clojure, получим неприятный результат:

    (get-joke "clojure")
    "null null"
    

    Чтобы понять, почему так получилось, исследуем ответ сервера:

    (def data
      (-> {:url "https://v2.jokeapi.dev/joke/Programming"
           :method :get
           :query-params {:contains "clojure"}
           :as :json}
          (client/request)
          (:body)))
    

    Данные:

    {:error true
     :internalError false
     :code 106
     :message "No matching joke found"
     :causedBy ["No jokes were found that match your provided filter(s)."]
     :additionalInfo
     "Error while finalizing joke filtering: No jokes were found that match your provided filter(s)."
     :timestamp 1651054623055}
    

    Сервер не нашел подходящих записей и вернул структуру с полем {:error true}. Перепишите функцию так, чтобы в случае ошибки мы получили nil, а не строку с null.

    Негативный ответ тоже пригодится в тестах. Запишите его в файл с понятным именем:

    (cheshire.core/generate-stream
      data (io/writer "joke-err.json") {:pretty true})
    

    Итак, разведка выполнена. Мы убедились, что сервис отвечает на запросы. Мы получили данные, которые позже используем в тестах. Стало ясно, как ведет себя сервис в случае ошибки. Во время экспериментов появились наброски кода, из которых легко составить конечную версию.

    Важно, что эти наброски опираются на реальные данные, а не документацию или систему классов. И то и другое может устареть и не давать реальной картины — что именно передается по сети. В случае с REPL подобной ошибки быть не может.

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

    Свой REPL

    Чтобы лучше понять устройство REPL, напишем свою реализацию. Подготовьте минимальный проект: создайте файл project.clj или deps.edn и структуру папок src/<project>. Назовем проект my-repl. Поместите в файл src/my_repl/core.clj следующий код:

    (ns my-repl
      (:gen-class))
    
    (defn -main [& args]
      (repl))
    

    Форма (:gen-class) в макросе ns означает, что при компиляции пространства получится одноименный класс. Это же пространство должно быть указано главным в файле project.clj:

    :main my-repl
    

    Функция -main — точка входа в будущий класс — запускает сеанс REPL. Что находится внутри (repl), пока неизвестно. Подготовим ее черновик:

    (defn repl []
      (loop []
        (let [input (read-line)
              expr (read-string input)
              result (eval expr)]
          (println result)
          (recur))))
    

    Это бесконечный цикл, где на каждом шаге происходит следующее:

    Read

    Функция read-line читает строку из стандартного канала ввода (stdin). Если канал пуст, система ожидает ввода с клавиатуры. Пользователь набирает текст в терминале и жмет Enter. В переменной input окажется строка.

    Функция read-string читает объект Clojure из строки. Числа, строки и другие примитивы выражаются сами в себя. Например, из строки “1” получим единицу. Символы остаются невычисленными: строка "(foo bar)" вернет список с символами foo и bar. В переменной expr окажется объект Clojure: символ, строка, число или их коллекция.

    => (read-string "(foo [1 false {:foo BAZ}])")
    ;; (foo [1 false {:foo BAZ}])
    

    Eval

    Функция eval принимает выражение и вычисляет его. В отличии от других языков, в Clojure eval принимает не строку, а форму, то есть примитив (число, символ, кейворд) или коллекцию. Примитивы вычисляются сами в себя:

    => (eval 1)
    1
    
    => (eval :foo)
    :foo
    

    Символы вычисляются в переменные текущего пространства. Например, символ + связан с функцией clojure.core/+, и его вычисление вернет объект функции:

    => (eval '+)
    #function[clojure.core/+]
    

    В вычислении можно сослаться на любую переменную, в том числе созданную вами:

    (def some-text "Hello")
    (eval '(str some-text " World!"))
    "Hello World!"
    

    Вариант посложнее со словарем и update-in:

    (def data {:a {:b 0}})
    (eval '(update-in data [:a :b] inc))
    {:a {:b 1}}
    

    Если переменной, на которую ссылается символ, нет, получим исключение:

    (eval 'dunno)
    Syntax error compiling at ...
    Unable to resolve symbol: dunno in this context
    

    Print

    Следующий шаг — печать. Результат eval выводится на экран обычной функцией println:

    (println result)
    

    Loop

    Оператор loop переносит нас к первому шагу — чтению с клавиатуры — и все повторяется.

    Хоть это и крайне сырая версия REPL, она работает. Скомпилируйте проект командой lein uberjar и запустите jar-файл:

    java -jar target/uberjar/repl-chapter-0.1.0-SNAPSHOT-standalone.jar
    

    Введите несколько выражений на Clojure, отделяя каждое клавишей Enter. После каждого появится результат вычисления. Приведем фрагмент сеанса:

    => (+ 1 2)
    3
    
    => (assoc {:one 1} :two 2)
    {:one 1, :two 2}
    
    => (defn add [a b] (+ a b))
    #'clojure.core/add
    
    => (add 4 5)
    9
    
    => (require '[clojure.string :as str])
    nil
    
    => (str/split "1 2 3" #"\s")
    [1 2 3]
    

    Мы поработали со словарями, определили функцию и вызвали ее, затем подключили модуль. Даже в таком примитивном REPL нам доступны все возможности языка.

    Улучшения

    Предлагаем читателю доработать наш самописный REPL — это будет отличная практика.

    Выход из цикла

    На текущий момент нельзя завершить REPL без нажатия Ctrl+C, что неудобно. Сделайте так, чтобы какое-то выражение было особым и означало остановку. Например, если пользователь ввел кейворд :repl/exit, REPL завершается. Проверка может выглядеть так:

    (let [input (read-line)
          expr (read-string input)]
      (when-not (= expr :repl/exit)
        ...))
    

    При вводе :repl/exit оператор loop не сработает, и REPL выйдет из цикла.

    Перехват исключений

    Если выражение приведет к ошибке, программа завершится аварийно. Как мы выяснили, исключения часто случаются в REPL, потому что в нем экспериментируют. Ошибка — не повод завершать программу. Оберните каждый шаг цикла в try/catch с классом Throwable, чтобы перехватывать все возможные ошибки:

    (defn repl []
      (loop []
        (let [[result e]
              (try
                [(-> (read-line)
                     (read-string)
                     (eval))
                 nil]
                (catch Throwable e
                  [nil e]))]
          (if e
            (binding [*out* *err*]
              (println (ex-message e)))
            (println result))
          (recur))))
    

    Скомпилируйте проект заново и запустите. На этот раз программа не “вывалится” в случае исключения, а напечатает сообщение:

    => 1
    1
    
    => (/ 0 0)
    Divide by zero
    

    Обратите внимание: форма recur не может быть внутри try/catch, поэтому идем на уловку. Выражение try возвращает пару, где первый элемент — результат, если не было ошибок, а второй — nil или ошибка, если что-то пошло не так. Прием с парой мы рассмотрели в первой книге в главе об исключениях.

    В зависимости от того, что мы получили — результат или ошибку, — выводим результат в stdout или stderr при помощи связывания binding. В данном примере мы только печатаем текст исключения. Для более детальной информации можно взять функцию print-stack-trace из модуля clojure.stacktrace.

    Еще одно замечание касается строки (if e ...). Мы проверяем исключение, а не результат, потому что результат вполне может быть nil.

    Обработчик исключения

    Чтобы сделать REPL более гибким, поместим обработку исключений в отдельную функцию:

    (defn exception-handler [e]
      (binding [*out* *err*]
        (println (ex-message e))))
    
    (loop []
      (...
       (if e
         (exception-handler e))))
    

    Пусть функция repl принимает параметр, чтобы задать свой обработчик исключения. Если он не задан, сработает функция по умолчанию, которую мы объявили выше. Для ясности переименуем ее в default-exception-handler. Далее перепишем repl:

    (defn repl [& [{:keys [exception-handler]}]]
      (let [ex-handler
            (or exception-handler
                default-exception-handler)]
        (loop []
          (...
            (if e
              (ex-handler e))))))
    

    Еще до того как мы вступим в цикл, переменной ex-handler назначен обработчик — переданный или заданный по умолчанию. Эта оптимизация нужна для того, чтобы не вычислять обработчик на каждом шаге.

    Запустите repl с обработчиком, который печатает тип исключения:

    (defn -main [& args]
      (repl {:exception-handler
             (fn [e] (println (type e)))}))
    

    Результат:

    => (/ 0 0)
    java.lang.ArithmeticException
    

    Красивая печать

    Функция println выводит данные в одну строку, что не подходит для вложенных коллекций. Чтобы вывод был понятен, нужны переносы строк и отступы. Воспользуйтесь функцией pprint из модуля clojure.pprint:

    (ns my-repl
      (:require
       [clojure.pprint :as pprint]))
    
    (defn repl [...]
      (loop []
        (let [...]
          (pprint/pprint result)
          (recur))))
    

    Опробуем красивую печать в действии. Понадобится большая коллекция, например словарь переменных среды:

    (pprint/pprint
      (into {} (System/getenv)))
    
    {"LEIN_VERSION" "2.9.5",
     "HOME" "/Users/ivan",
     "LC_TERMINAL_VERSION" "3.3.12",
     "USER" "ivan"
     ...
     }
    

    Данные будут напечатаны построчно, что гораздо удобней для чтения. Обратите внимание, что красивая печать работает только для типов Clojure. Поэтому мы приводим результат (System/getenv), экземпляр класса UnmodifiableMap, к словарю функцией into.

    В первой книге мы упоминали, что на pprint влияют глобальные динамические переменные *print-length* и *print-level* — максимальные длина и глубина коллекции. Пусть наш REPL принимает значения этих переменных. Если они заданы, цикл запускается в форме binding с переопределением длины и глубины. Минимальные правки:

    (defn repl [& [{:keys [print-level
                           print-length]}]]
      (binding [*print-level*
                (or print-level *print-level*)
                *print-length*
                (or print-length *print-length*)]
        ...))
    
    (defn -main [& args]
      (repl {:print-length 3}))
    

    Запустив новый REPL, введите коллекцию длиннее трех элементов. При печати вы увидите ее усеченную версию:

    => [1 2 3 4 5]
    [1 2 3 ...]
    

    Приглашение

    Стандартный REPL показывает приглашение вида user=>. Это удобно по двум причинам. Во-первых, ясно, в каком пространстве мы находимся сейчас — в процессе работы пространство переключают. Во-вторых, стрелка подсказывает, что от нас ожидают ввод.

    Добавьте печать приглашения перед вводом с клавиатуры (вызовом read-line). Для начала ограничимся статичной строкой:

    (loop []
      (print "repl=> ")
      (flush)
      (let [...]
        ...))
    

    Вызов (flush) необходим, чтобы отправить вывод в терминал, не дожидаясь заполнения буфера. С приглашением REPL выглядит живее:

    repl=> :hello/repl
    :hello/repl
    
    repl=> {:foo "test"}
    {:foo test}
    

    Логично, чтобы за приглашение отвечала функция get-prompt, которая принимает текущее пространство:

    (defn get-prompt [this-ns]
      (format "%s=> " (ns-name this-ns)))
    
    (defn repl []
      (binding [*ns* *ns*]
        (loop []
          (print (get-prompt *ns*))
          ...)))
    

    При смене пространства в REPL меняется и приглашение:

    clojure.core=> (in-ns 'repl-test)
    
    repl-test=> (clojure.core/refer-clojure)
    nil
    
    repl-test=> (+ 1 2)
    3
    

    Обратите внимание на форму (binding [*ns* *ns*] ...) перед loop. Без нее не получится сменить пространство: функция in-ns меняет переменную *ns* формой (set! *ns* ...), что невозможно вне макроса binding.

    Доработайте REPL так, чтобы можно было задать свой обработчик приглашения. Напишите обработчик, который выводит текущее время:

    18:12=> ...
    18:14=> ...
    

    Переменная результата

    Еще одна полезная доработка — хранить последний результат в переменной, чтобы позже ссылаться на него. Назовем переменную -r (result).

    Исправим REPL: поместим цикл в форму with-local-vars. Макрос задает локальные переменные, которые напоминают атомы. Чтобы изменить переменную, вызывают var-set. Для значения служит функция var-get или оператор @ (deref). Новая версия REPL:

    (defn repl []
      (with-local-vars [-r nil]
        (loop []
          (let [input (read-line)
                expr (read-string input)
                result
                (case expr
                  -r (var-get -r)
                  (eval
                   `(let [~'-r ~(var-get -r)]
                      ~expr)))]
            (var-set -r result)
            (println result)
            (recur)))))
    

    Обратите внимание на то, как вычисляется форма expr. С помощью оператора case мы проверяем: если поступил символ -r, вернем значение переменной -r. Ввод -r считается особенным, потому что вычисляются без eval.

    Чтобы сослаться на -r в выражении, например (+ -r 3), идут на трюк. Форма expr погружается в макрос let, где символ -r связан со значением -r. Дело в том, eval не учитывает локальные переменные, и без let мы получим ошибку, что символ -r неизвестен.

    Другими словами: вычисление (eval '(+ -r 3)) вернет ошибку, потому что символ -r неизвестен. Однако (eval '(let [-r 4] (+ -r 3))) сработает без ошибки, потому что -r связан с 4.

    Запустите REPL и проверьте, что в -r остается результат, при этом на него можно сослаться в выражении:

    => (+ 1 2 3)
    6
    
    => -r
    6
    
    => (* -r 3)
    18
    
    => -r
    18
    

    Доработайте REPL так, чтобы, кроме результата, он хранил последнее исключение в переменной -e. Например:

    (/ 1 0)
    ;; ... Stacktrace ...
    
    -e
    ;; Execution error (ArithmeticException)...
    ;; Divide by zero
    

    Для этого добавьте в макрос with-local-vars привязку [... -e nil]. При помощи try/catch перехватывайте ошибку. Если что-то поймано, запишите исключение в -e:

    (with-local-vars [-r nil -e nil]
      ...
      (try
        ...
        (catch Throwable e
          (var-set -e e))))
    

    Многострочный ввод

    До сих пор мы вводили выражения под одной строке. Теперь мы хотим задать функцию с переносом после сигнатуры:

    => (defn add [a b]
        (+ a b))
    

    Если нажать ввод после b], произойдет следующее. Клавиша Enter завершит прием символов, и в переменной окажется строка "(defn add [a b]". Функция read-string не сможет ее разобрать и бросит исключение:

    => (read-string "(defn add [a b]")
    Execution error at ...
    EOF while reading
    

    Кроме того, у read-string еще одна особенность: она читает только один объект из строки, что видно из примеров:

    => (read-string "1 2")
    1
    
    =>  (read-string "(+ 1 2) (- 1 2)")
    (+ 1 2)
    

    Вторая форма была отброшена.

    Чтобы исправить эти неприятности, перед вызовом read-line мы должны убедиться, что форма завершена. Для этого проверим строку на баланс скобок: на каждую открывающую приходится закрывающая того же типа (круглая, квадратная, фигурная). Если в строке незакрытые скобки, мы запрашиваем еще одну строку и продолжаем учет скобок. Как только все скобки закрыты, накопленные строки вычисляются как одно выражение.

    Чтобы выделить многострочный ввод визуально, каждая следующая строка предваряется отступом:

    (defn add [a b]
    ..(let [c (+ a b)]
    ....(* c 3)))
    

    Длина отступа (число точек) равна уровню формы — числу вложенных скобок, умноженному на два.

    Для учета скобок подойдет стек — структура данных, которая работает по принципу FILO (First In Last Out, первым пришел — последним ушел). В стек добавляют и извлекают из него элементы. Ограничение в том, что извлечь их можно только в обратном порядке. Например, если добавить в стек символы (, [, {, то при извлечении получим {, [, (.

    При анализе строки мы перебираем ее символы. Открывающие скобки попадают в стек. Как только мы встретили закрывающую скобку, происходит следующее:

    • убираем с вершины стека последнюю скобку;
    • убеждаемся, что она парная к найденной. Если это не так, бросаем исключение.

    Например, если в стеке содержатся элементы (, [, { и мы встретили закрывающую фигурную скобку, все в порядке: она относится к элементу на вершине {. Если же попалась квадратная закрывающая, это говорит об ошибке в синтаксисе.

    ;; ok
    "(..[..{..}......"
              ^
    
    ;; error
    "(..[..{..]......"
              ^
    

    Для начала опишем стек. Это функция, которая порождает функцию, замкнутую на атоме. Внутренняя функция принимает различные зные команды. Оператор case определяет их логику. У функции два тела: команды без аргументов и с одним аргументом.

    Стек поддерживает команды :count (узнать число элементов), :empty? (проверка на пустоту), :pop (извлечь последний элемент) и :push (добавить элемент). От других команд :push отличается тем, что принимает аргумент — значение, которое добавляют в стек. Поэтому :push описан во втором теле.

    (defn make-stack []
      (let [-stack (atom nil)]
        (fn stack
          ([cmd]
           (case cmd
             :count (count @-stack)
             :empty? (zero? (count @-stack))
             :pop (let [item (first @-stack)]
                    (swap! -stack rest)
                    item)))
          ([cmd arg]
           (case cmd
             :push
             (swap! -stack conj arg))))))
    

    Атом хранит обычный список. Если точнее, изначально он хранит nil, при добавлении элемента к которому получится список. Функция conj добавляет элемент в голову списка. Вот почему вершину стека получают функцией first, а усекают функцией rest.

    Стек в действии:

    (def s (make-stack))
    
    (s :count)  ;; 0
    (s :empty?) ;; true
    (s :push 1) ;; (1)
    (s :push 2) ;; (2 1)
    (s :push 3) ;; (3 2 1)
    (s :count)  ;; 3
    (s :pop)    ;; 3
    (s :pop)    ;; 2
    (s :pop)    ;; 1
    (s :empty?) ;; true
    

    Наш стек является изменяемым объектом. Более “кложурный” способ в том, чтобы сделать стек неизменяемым: каждая операция возвращала бы его копию подобно функциям assoc или update. Но для разнообразия мы решили поработать с изменяемым стеком.

    Анализ строки сводится к тому, чтобы сперва наполнить стек открывающими скобками, а затем опустошить закрывающими. Если в итоге стек пуст, строка сбалансирована.

    Напишем функцию multi-input, которая читает ввод с клавиатуры до тех пор, пока выражение не сбалансировано. Считанные строки объединяются в одну пробелом. При вводе очередной строки покажем уровень вложенности отступами. Вот алгоритм функции:

    (defn multi-input []
      (let [stack (make-stack)]
        (loop [result ""]
          (print (make-indent stack))
          (flush)
          (let [line (read-line)
                result (str result " " line)]
            (consume-line stack line)
            (if (stack :empty?)
              result
              (recur result))))))
    

    Код довольно короткий и опирается на две служебные функции. Первая make-indent строит отступ из точек; его длина равна двойному числу элементов в стеке:

    (defn make-indent [stack]
      (str/join (repeat (* (stack :count) 2) ".")))
    

    Вторая функция consume-line сложнее. Она принимает стек и строку, проходит по символам и корректирует стек. Для корректировки нужен словарь парных скобок. Его ключи и значения — символы типа Char:

    (def brace-pairs
      {\( \)
       \[ \]
       \{ \}})
    

    Понадобится зеркальная копия этого словаря, чтобы по закрывающей скобке найти открывающую:

    (def brace-oppos
      (into {} (for [[k v] brace-pairs]
                 [v k])))
    

    Код насыщения стека:

    (defn consume-line [stack line]
      (doseq [char line]
        (cond
          (contains? brace-pairs char)
          (stack :push char)
    
          (contains? brace-oppos char)
          (let [char-oppos
                (get brace-oppos char)
                char-lead
                (stack :pop)]
            (when-not (= char-lead char-oppos)
              (throw
               (new Exception
                    (format "Unbalanced expression: %s...%s"
                            char-lead char))))))))
    

    Логика следующая: если символ — открывающая скобка (входит в brace-pairs), добавить ее в стек. Если закрывающая (входит в brace-oppos), найти по ней открывающую (переменная char-oppos). Далее получить элемент с вершины стека методом :pop (переменная char-lead). Если переменные не равны, бросить исключение.

    Вернитесь в функцию repl и замените (read-line) на (multi-input). Скомпилируйте проект и опробуйте REPL в действии. Вот что получилось у автора:

    => (+ 1 2 3
    => ..3 4 5)
    18
    
    => (defn add [a b]
    => ..(+ a b))
    #'clojure.core/add
    
    => (assoc-in {:foo 1
    => ....:bar 2
    => ....:baz 3
    => ....}
    => ..[:test :hello]
    => ..3
    => ..)
    {:bar 2 :baz 3 :foo 1 :test {:hello 3}}
    

    Очевидно, с таким подходом легче ввести длинное выражение. Проверьте код с неверными скобками. В исключении сказано, какие именно скобки были причиной.

    => (+ 1 2 3]
    java.lang.Exception: Unbalanced expression: (...]
    

    Заметим, что попытка извлечь элемент из пустого стека тоже считается ошибкой. Это может случится при работе со строкой "(+ 1 2 3))". Предпоследняя скобка очистит стек, но последняя обратится к пустому стеку, что говорит о нарушении. Без доработок мы получим сообщение с null, что понятно:

    Unbalanced expression: null...)
    

    Перепишем команду :pop так, чтобы учесть пустой стек:

    :pop (if (empty? @-stack)
           (throw (new Exception "Stack is empty!"))
           (let [item (first @-stack)]
             (swap! -stack rest)
             item))
    

    Если ввести код с лишними скобками на конце, получим исключение:

    java.lang.Exception: Stack is empty!
    

    Подсчет скобок может дать осечку для внутренних строк. Предположим, мы объявили строку со смайликом внутри:

    (def text "Hello Clojure :-)")
    

    Наш анализатор не знает, что нужно игнорировать скобки внутри строки. В результате получим ошибку о пустом стеке. Доработайте (multi-input) так, чтобы при переходе через двойную кавычку включался режим “в строке”, когда скобки не добавляются в стек. При выходе из строки флаг отключается.

    REPL в REPL

    Еще одна интересная задача: что случится, если вызвать в сеансе функцию (repl)? Другими словами, запустить REPL в REPL?

    Ответ — вы запустите новый сеанс, а прежний повиснет в его ожидании. В новом REPL доступны изменения, что вы сделали раньше. При завершении вы вернетесь в прежний REPL и продолжите работу. Покажем это на примере (напомним, что ввод :repl/exit завершает REPL):

    => (def x (+ 1 2))
    #'my-repl/x
    
    => (repl)
    
    => (* x x)
    9
    
    => :repl/exit
    nil
    
    => (println "still in the REPL")
    still in the REPL
    nil
    

    Видно, что переменная x, объявленная во внешнем сеансе, доступна во внутреннем. Ввод :repl/exit возвращает нас в прежний REPL, не завершая программу. Усложним сценарий, поместив (repl) в середину кода:

    => (let [a 1 b 2] (repl) (+ a b))
    
    => (+ 3 4)
    7
    
    => :repl/exit
    3
    

    В этом примере форма let повиснет до тех пор, пока не завершится внутренний REPL. Пребывая в нем, мы ввели выражение (+ 3 4). После выхода получим результат всей формы — тройку.

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

    Доступ к локальным переменным

    Должно быть, вы догадались: вложенный REPL удобен в качестве отладчика. Он прерывает код и поэтому работает как точка останова. С его помощью мы бы остановились на сложном участке кода, чтобы исследовать переменные.

    Недостаток нашего REPL в том, у него нет доступа локальным переменным. Предопложим, мы запустили REPL внутри формы let или функции с аргументами:

    (let [a 1 b 2]
      (my-repl/repl)
      (+ a b))
    
    ;; or
    
    (defn add [a b]
      (my-repl/repl)
      (+ a b))
    
    (add 1 2)
    

    Логично ожидать, что при вводе a или b получим единицу и двойку. Однако это не так — сославшись на локальную переменную, получим исключение о том, что символ неизвестен:

    => a
    java.lang.RuntimeException: Unable to resolve symbol: a in this context
    

    Дело в том, что функция eval учитывает только глобальные переменные, то есть заданные при помощи def. Предупреждая ваше огорчение, скажем — все-таки можно сделать так, чтобы локальные переменные были доступны. В середине главы мы напишем свой отладчик, где эта проблема решена. Пока что предложим читателю подумать над этим вопросом.

    Полезные функции REPL

    Модуль clojure.repl содержит функции и макросы для интерактивного сеанса. В основном они служат для того, чтобы получить информацию о модулях переменных, не покидая REPL. Чтобы подключить модуль, введите команду use:

    (use 'clojure.repl)
    

    Содержимое clojure.repl объединится с текущим пространством имен, и не понадобится псевдоним.

    Функция apropos ищет определение по строке или регулярному выражению. Для слова "update" найдутся следующие кандидаты:

    (apropos "update")
    
    (clojure.core/update
     clojure.core/update-in
     clojure.core/update-keys
     clojure.core/update-proxy
     clojure.core/update-vals)
    

    Если объявить переменную со словом "update" в названии:

    (def updated-result 42)
    

    и выполнить поиск еще раз, в выборке окажется символ user/updated-result.

    Макрос dir выводит все публичные переменные пространства:

    (dir clojure.string)
    
    blank?
    capitalize
    ends-with?
    escape
    ...
    

    Знакомый макрос (doc ...) печатает справку определения. Если это функция или макрос, вы увидите возможные параметры вызова.

    (defn add
      "Add two numbers."
      [a b]
      (+ a b))
    
    => (doc add)
    -------------------------
    user/add
    ([a b])
      Add two numbers.
    

    Если возникло исключение, REPL напечатает только его класс и сообщение, чтобы не тратить место на стек-трейс. Функция pst (сокращение от print stack trace) принудительно выводит полное исключение.

    => (/ 1 0)
    Execution error (ArithmeticException) at user/eval175 (REPL:1).
    Divide by zero
    
    => (pst)
    ArithmeticException Divide by zero
    	clojure.lang.Numbers.divide (Numbers.java:190)
    	clojure.lang.Numbers.divide (Numbers.java:3911)
    	user/eval175 (NO_SOURCE_FILE:1)
        ...
    	clojure.main/repl/fn--9215 (main.clj:458)
    	clojure.main/repl (main.clj:458)
    

    Эти и другие возможности пригодятся вам в долгих сеансах REPL. Ознакомьтесь с ними в документации к модулю clojure.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 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 воспринимается как глоток воздуха. Админка товара открывается секунду — так можно было?

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

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

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

  • Правила пользования почтой

    Начинающие программисты спрашивают, какую технологию учить: Питон, Джаву и прочее. Отвечая на эти вопросы, часто забываешь об электронной почте. Закрываю упущение: учитесь электронной почте! Это не так просто: вокруг почты сложился особый этикет, о котором не расскажут в онлайн-курсах. Вы должны знать его перед тем, как начнете искать работу.

    Прежде всего: в деловой переписке почта важнее мессаджеров. Если есть несколько способов связаться с человеком, сначала пишите на почту. В мессаджер можно писать только по взаимному согласию или если нет других контактов.

    Раздражает, когда в личку врывается незнакомый человек и вываливает стену текста. Или, наоборот, пишет сто сообщений словосочетаниями. Если у вас долгий разговор, представьтесь в мессаджере и попросите почту, чтобы написать туда развернутый текст.

    В отправителе не должно быть ников вроде “nagibator666” или LenuSiK. Плохо, когда имя сгенерировано по шаблону “фамилия.годрождения”, например semenova.81@mail.ru. В отправителе должны быть имя и фамилия, желательно на том языке, на котором говорит получатель, скажем, Ivan Grishaev. Имя не должно быть двояким: вместо Alex пишите Alexey или Alexander.

    Самое важное правило: при ответе не цитируйте все письмо. Прямо сейчас, не читая этот текст до конца, откройте почтовый клиент и поставьте нужную настройку. На маке это последний чекбокс на закладке Composing. Называется он странно, но работает как надо: при ответе будет процитирован только выделенный текст, а если не выделено ничего, то ответ будет по умолчанию пустым.

    Не помню, как сделать то же самое в Gmail, но там у меня выработалась привычка: после нажатия кнопки Reply я автоматом выполнял Ctrl-A Delete, чтобы стереть все из поля ввода.

    Цитирование письма уходит корнями в прошлое. Раньше почтовые сервера не умели строить цепочки писем. Каждое письмо считалось отдельным. Чтобы был ясен контекст, старое письмо добавлялось цитатой к новому. Это в высшей степени избыточно и неэффективно. Позже письма научились сопоставлять по теме и префиксам Re, что тоже уродливо. И лишь затем придумали машинные заголовки с идентификаторами, где указано, на что отвечает это письмо.

    Беду с цитированием знает каждый, кто работал в больших фирмах. В обсуждении по почте борода старых писем растет как снежный ком, и каждый клиент работает с ними по своим правилам: отступами, тильдами или вертикальными чертами. Рано или поздно в тред отвечает красноглазик с консольным клиентом на Perl, где борода цитат выделена какими-нибудь >>>. В результате все видят пять экранов мусора с текстом недельной давности. Не недо так.

    Заполняйте письмо снизу вверх. Как это понимать? Обычно мы указываем получателя, тему, пишем текст и прикладываем файл. Из всех этих полей обязательно только первое — получатель. Как только он заполнен, легко отправить письмо неверным нажатием или комбинацией клавиш. За подобное письмо стыдно, оно вносит непонимание, приходится в спешке отправлять другое. Этого легко избежать методом “снизу вверх”.

    Если вы отправляете файл, сначала приложите файл. Затем напишите текст. Введите тему. И только потом — отправителя. С таким подходом невозможно отправить письмо, пока оно не заполнено.

    Что делать, если вы забыли приложить файл? Обычно его отправляют вдогонку со словами “забыл файлик, вот он”. Не делайте так, потому что на стороне получателя файл будет оторван от основного письма. Как его потом найти? Письмо с текстом человек найдет по ключевым словам, но как быть с файлом? Правильно сделать так: отправить повторное письмо с файлом и написать, что прошлое письмо без файла ушло по ошибке и его надо удалить. В этом случае все останутся довольны.

    Ближайшая аналогия: вы отправили исходный код, и затем пишете: ой, не хватает одного файлика. Положите его в папку foobar/lulz, и все заработает. Это непрофессионально. Вы либо сдаете работу, либо нет. Письмо без файла, когда он должен там быть — испорченное письмо. Вместо попыток его исправить отправьте новое, на этот раз правильное.

    Еще о файлах. Если вы отправляете документы для просмотра, используйте PDF. Не ждите, что у собеседника установлен Word, Excel или Powerpoint — эти программы платные и открываются в сто раз медленней, чем PDF. Исключение возможно, когда вы оба работаете в этих программах.

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

    Если архиватор, то только zip. Своими rar, tar.gz и прочими платформо-зависимыми штучками вы огорчите получателя.

    Файлы больше 10 Мб выносите в облачные хранилища. Желательно такие, что отдают файл по прямой ссылке, например S3. Если ссылка ведет на веб-страницу, где нужно доказать, что ты не робот, это грусть и печаль. По той же причине не пользуйтесь хостингом изображений — там показывают рекламу, открывают дичь в новых вкладках, словом, сплошное издевательство.

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

    Подпись должна быть короткой, в идеале только имя. В крайнем случае — должность и минимальные контакты: телефон и ник в мессаджерах. Писать туда домашний телефон, номер ICQ и прочую дрянь не нужно. В подписи не должно быть логотипов и ссылок.

    Если это вручную написанное письмо, а не рекламная рассылка, его тип должен быть plain text (без форматирования). Никаких болдов, италиков и прочих офисных штучек, ведь текст — уже мощный инструмент. Поток сознания нужно разбить на абзацы или пункты, важные вещи прописать отдельно. В крайнем случае поставить восклицательный знак или выделить звездочками.

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

    Как видите, правил вышло прилично. Никто не ждет исполнения их всех, но применять их однозначно стоит. Остановитесь на чем-нибудь простом, например не цитировать все письмо в ответе, а затем каждый месяц вводите новую практику. Через несколько лет вы сами будете учить новичков почтовому этикету.

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

  • Черно-белый режим

    Иногда встречаю в интернете статьи о том, как уменьшить вред от гаджетов для глаз. Пишут обычные вещи: уменьшить яркость, включить ночной режим (меньше синего и больше желтого) и в том числе — черно-белый режим, когда цвета меняются на градации серого. Типа, меньше нагрузка на сетчатку и все такое.

    Опровергаю: включать черно-белый режим не нужно. Несколько лет назад я проводил эксперимент — включил на ноуте и телефоне такой режим. В MacOS это делается галкой в меню Settings / Accessibility / Display / Color Filters, на айфоне — схожим образом.

    Интерфейс выглядел так:

    Почему этого делать не нужно? Ответ — при выключенном цвете нагрузка на глаза, как ни странно, больше. Объяснение этому простое: на сетчатке находятся разные рецепторы, в том числе палочки и колбочки. У них разные функции: первые отвечают за свет, вторые за цвет. Это ортогональные вещи: например, кошки отлично видят в темноте, но хуже нас различают цвета.

    Убрав цвет, вы по сути выключаете колбочки, а это две трети сетчатки. Теперь палочки отвечают за поиск иконки или строки кода на мониторе. Я работал в таком режиме и быстро заметил: в черно-белом режиме трудно найти нужный элемент, глаза напрягаются. Выходит обратная вещь — вместо помощи только вредишь глазам.

    Восприимчивость к цвету крайне важна. У нас, людей, этот навык крайне развит по сравнению с животными. Кто сорвал красный плод, тот выжил, а кому достался зеленый — увы. Мозг получает колоссальную информацию из цвета, и добровольно лишать себя этого по крайней мере странно.

    Возвращаясь к первоначальному вопросу — как защитить глаза от гаджетов в вечернее и ночное время? Очень просто — не пользоваться гаджетами. Ночной там режим или нет, желтый фон, пониженное мерцания — все это ерунда. Экран — это тысяча мелких фонариков, которые светят вам в глаза. Регулируйте гаджет как угодно, сути это не меняет: вы смотрите на источник света, пусть и рассеянный. При любых настройках он будет вреден в вечернее время.

    Наоборот, книга и вообще бумага не светятся. Она только отражает свет лампы, но это свет другого рода. Будучи отраженным от бумаги, он не вредит вам.

    Это не значит, что я не туплю в экран вечером. Увы, порой случается. Однако понимаю грань допустимого и не обманываю себя режимами. Лучший режим гаджета перед сном — ВЫКЛ.

  • Вертикальный режим

    Вчера попробовал вертикальный режим монитора. Это когда экран повернут на 90 градусов. Необычные ощущения: окна вытягиваются по высоте, а не ширине. Появляется зажатость по сторонам, начинаешь двигать шеей. Выглядит так:

    Меня хватило где-то на час. Природу не обманешь: уж если наши глаза расположены по горизонтали, а не один над другим, то и монитор должен простираться в этом направлении. Словом, никакого секрета нет, только неудобство.

    Мелкие плюсы разбиваются о суровую реальность. Ради интереса пошарьте экран кому-либо в вертикальном режиме — вас станут ненавидеть. Потому что собеседник увидит следующее:

    Как здесь что-либо прочитать, я не знаю. То же самое в обратную сторону: если вывести нормальный шаринг на вертикальный экран, получим:

    Возразят: шарь не весь экран, а только окно. Так даже лучше, потому что собеседник не увидит вкладки браузера, переписку с женой и прочее. Я тоже шарю только Емакс. Но иногда работа идет сразу в редакторе, терминале и браузере, и тут никуда не денешься. Пошарил, и начинается долгая песня с увеличением шрифтов, зумов и прочего.

    Словом, хороший эксперимент, но нет.

Страница 1 из 57