• Virtuoso: a Clojure wrapper for virtual threads

    Virtuoso is small wrapper on top of virtual threads introduced in Java 21.

    About

    The recent release of Java 21 introduced virtual threads to the scene. It’s a nice feature that allows you to run imperative code, such as it was written in an asynchronous way. This library is a naive attempt to gain something from the virtual threads.

    Installation

    Lein

    [com.github.igrishaev/virtuoso "0.1.0"]
    

    Deps/CLI

    {com.github.igrishaev/virtuoso {:mvn/version "0.1.0"}}
    

    Usage

    First, import the library:

    (require '[virtuoso.core :as v])
    

    with-executor

    The with-executor wraps a block of code binding a new instance of VirtualThreadPerTaskExecutor to the passed symbol:

    (v/with-executor [exe]
      (do-this ...)
      (do-that ...))
    

    Above, the executor is bound to the exe symbol. Exiting from the macro will trigger closing the executor, which, in turn, leads to blocking until all the tasks sent to it are complete. The with-executor macro, although it might be used on your code, is instead a building material for other macros.

    future-via

    The future-via macro spawns a new virtual future through a previously open executor. You can generate as many futures as you want due to the nature of virtual threads: there might be millions of them.

    (v/with-executor [exe]
      (let [f1 (v/future-via exe
                 (do-this ...))
            f2 (v/future-via exe
                 (do-that ...))]
        [@f1 @f2]))
    

    Virtual futures give performance gain only when the code they wrap makes IO. Instead, if you run CPU-based computations in virtual threads, the performance suffers due to continuations and moving the stack trace from the stack to the heap and back.

    futures(!)

    The futures macro takes a series of forms. It spawns a new virtual thread executor and wraps each form into a future bound to that executor. The result is a vector of Future objects. To obtain values, pass the result through (map/mapv deref ...):

    (let [futs
          (v/futures
           (io-heavy-task-1 ...)
           (io-heavy-task-2 ...)
           (io-heavy-task-3 ...))]
      (mapv deref futs))
    

    Right before you exit the macro, it closes the executor, which leads to blicking until all the tasks are complete.

    Pay attention that deref-ing a failed future leads to throwing an exception. That’s why the macro doesn’t dereference the futures for you, as it doesn’t know how to handle errors. But if you don’t care about exception handling, there is a futures! macro that does it for you:

    (v/futures!
      (io-heavy-task-1 ...)
      (io-heavy-task-2 ...)
      (io-heavy-task-3 ...))
    

    The result will be vector of dereferenced values.

    thread

    The thread macro spawns and starts a new virtual thread using the (Thread/ofVirtual) call. Threads in Java do not return values; they can only be join-ed or interrupted. Use this macro when interested in a Thread object but not the result.

    (let [thread1
          (v/thread
            (some-long-task ...))
    
          thread2
          (v/thread
            (some-long-task ...))]
    
      (.join thread1)
      (.join thread2))
    

    pmap(!)

    The pmap function acts like the standard clojure.core/pmap: it takes a function and a collection (or more collections). It opens a new virtual executor and submits each calculation step to the executor. The result is a vector of futures. The function closes the executor afterwards, blocking until all the tasks are complete.

    (let [futs
          (v/pmap get-user-from-api [1 2 3])]
      (mapv deref futs))
    

    Or:

    (let [futs
          (v/pmap get-some-entity                ;; assuming it accepts id and status
                  [1 2 3]                        ;; ids
                  ["active" "pending" "deleted"] ;; statuses
                  )]
      (mapv deref futs))
    

    The pmap! version of this function dereferences all the results for you with no exception handling:

    (v/pmap! get-user-from-api [1 2 3])
    ;; [{:id 1...}, {:id 2...}, {:id 3...}]
    

    each(!)

    The each macro is a wrapper on top of pmap. It binds each item from a collection to a given symbol and submits a code block into a virtual executor. The result is a vector of futures; exiting the macro closes the executor.

    (let [futs
          (v/each [id [1 2 3]]
            (log/info...)
            (try
              (get-entity-by-id id)
              (catch Throwable e
                (log/error e ...))))]
      (is (= [{...}, {...}, {...}] (mapv deref futs))))
    

    The each! macro acts the same but dereferences all the futures with no error handling.

    Measurements

    There is a development dev/src/bench.clj file with some trivial measurements. Imagine you want to download 100 of URLs. You can do it sequentially with mapv, semi-parallel with pmap, and fully parallel with pmap from this library. Here are the timings made on my machine:

    (time
     (count
      (map download URLS)))
    "Elapsed time: 45846.601717 msecs"
    
    (time
     (count
      (pmap download URLS)))
    "Elapsed time: 3343.254302 msecs"
    
    (time
     (count
      (v/pmap! download URLS)))
    "Elapsed time: 1452.514165 msecs"
    

    45, 3.3, and 1.4 seconds favour the virtual threads approach.

    The following links helped me a lot to dive into virtual threads, and I highly recommend reading and watching them:

  • PG docs, part 7. COPY IN/FROM

    (This is a new documentation chapter from the PG project.)

    ToC

    In this chapter:

    Theory

    The recent update of pg-client library introduces various ways to COPY the data into or from the database. It’s much more flexible than the official JDBC Postgres driver’s standard CopyManager class.

    To remind you, COPY is a massive way of writing or reading data. Copying IN is much faster than inserting the rows by chunks. Postgres starts to read the data immediately without waiting for the last bit of data to arrive. You can copy into the same table in parallel threads. The same applies to copying out: if you want to dump a table into a file, use COPY FROM with an OutputStream OutputStream rather than selecting everything in memory.

    The main disadvantage of JDBC CopyManager is, that it doesn’t do anything about data encoding and encoding. It accepts either an InputStream or an OutputStream assuming you encode the data on your own. It means, right before you copy the data to the database, you’ve got to manually encode them into CSV.

    This is not as easy as you might think. When encoding values into CSV, it coerces everything to a string using str. That’s OK for most of the primitive types as numbers, booleans or strings: their Clojure representation matches the way they’re represented in Postgres. But it doesn’t work for complex types like arrays. If you write a vector of [1 2 3] in CSV you’ll get "[1 2 3]" which is an improper Postgres value. It must have been {1, 2, 3} instead.

    Another flaw of JDBC CopyManager is, that it doesn’t split the data by rows when sending them into the database. It simply reads 2Kb of bytes from an InputStream and writes them to a socket. At the same time, the PostgreSQL documentation recommends splitting the data chunks by rows:

    The message boundaries are not required to have anything to do with row boundaries, although that is often a reasonable choice

    Moreover, PostgreSQL supports not only CSV but also text and binary formats. The text format is somewhat CSV with different separators so it’s not so important. But the binary format is indeed! Binary-encoded data are faster to parse and process and thus are preferable when dealing with vast chunks of data.

    CSV vs Binary

    Here are a couple of measurements I made on my local machine. I made two files containing 10 million rows: in CSV and in binary format. Then I used the official CopyManager to copy these files in the database. All the server settings were default; the machine was an Apple M1 Max 32Gb with 10 Cores.

    Single thread COPY

    Rows Format Time, sec
    10M binary 17.4
    10M CSV 51.2

    Parallel COPY

    Binary:

    Rows Threads Chunk Format Time, sec
    10M 8 10k binary 11.3
    10M 4 10k binary 13.7
    10M 1 10k binary 28.6

    CSV:

    Rows Threads Chunk Format Time, sec
    10M 8 10k CSV 10.6
    10M 4 10k CSV 19.9
    10M 1 10k CSV 71.7

    It’s plain to see that binary encoding is three times faster than CSV. 17 vs 51 seconds is a significant difference one cannot ignore.

    The good news is, the PG library does support binary encoding. It also allows you to perform COPY operations without encoding them manually. The library doesn’t make any InputStreams in the background: it encodes the rows one by one and sends them directly into the database. It also supports binary format of encoding which is a matter of passing a parameter. Also, it does split the data chunks by rows, not by the size of the buffer.

    Usage

    Establish a connection to the database first:

    (require '[pg.client :as pg])
    
    (def conn (pg/connect {...}))
    

    COPY out

    The copy-out function dumps a table or a query into a file. It accepts a connection object, a SQL expression describing the table, the columns, the format and other details, and an instance of an OutputStream. The rows from the table or a query get sent to that stream. The function returns a number of rows processed.

    (let [sql
          "COPY (select s.x as x, s.x * s.x as square from generate_series(1, 9) as s(x))
          TO STDOUT WITH (FORMAT CSV)"
    
          out
          (new ByteArrayOutputStream)]
    
      (pg/copy-out conn sql out))
    

    The expression above returns 9 (the number of rows). The actual rows are now in the out variable that stores bytes.

    Of course, for massive data it’s better to use not ByteArrayOutputStream but FileOutputStream. You can produce it as follows:

    (with-open [out (-> "/some/file.csv"
                        io/file
                        io/output-stream)]
      (pg/copy-out conn sql out))
    

    The PG library doesn’t close the stream assuming you may write multiple data into a single stream. It’s up to you when to close it.

    To dump the data into a binary file, add the WITH (FORMAT BINARY) clause to the SQL expression. Binary files are more difficult to parse yet they’re faster in processing.

    COPY IN from stream

    The copy-in function copies the data from in InputStream into the database. The payload of the stream is either produced by the previous copy-out function or manually by dumping the data into CSV/binary format. The function returns the number or rows processed by the server.

    (def in-stream
      (-> "/some/file.csv" io/file io/input-stream))
    
    (pg/copy-in conn
                "copy foo (id, name, active) from STDIN WITH (FORMAT CSV)"
                in-stream)
    
    ;; returns 6
    

    Again, it doesn’t close the input stream. Use the with-open macro to close it explicitly.

    The next two functions are more interesting as they bring functionality missing in the JDBC.

    COPY IN rows

    The copy-in-rows function takes a sequence of rows and sends them into the database one by one. It doesn’t do any intermediate steps like dumping them into an InputStream first. Everything is done on the fly.

    The function takes a connection, a SQL expression, and a sequence of rows. A row is a sequence of values. The result is a number of rows copied into the database.

    (pg/copy-in-rows conn
                     "copy foo (id, name, active, note) from STDIN WITH (FORMAT CSV)"
                     [[1 "Ivan" true nil]
                      [2 "Juan" false "kek"]])
    ;; 2
    

    The fourth optional parameter is a map of options. At the moment, the following options are supported:

    name default example (or enum) description
    :sep ,   a character to separate columns in CSV/text formats
    :end \r\n   a line-ending sequence of characters in CSV/text
    :null empty string   a string to represent NULL in CSV/text
    :oids nil [oid/int2 nil oid/date], {0 oid/int2, 2 oid/date} type hints for proper value encoding. Either a vector or OIDs, or a map of {index => OID}
    :format :csv :csv, :bin, :txt a keyword to specify the format of a payload.

    Copy rows in CSV with custom column separators and NULL representation:

    (pg/copy-in-rows conn
                     "COPY foo (id, name, active, note) FROM STDIN WITH (FORMAT CSV, NULL 'NULL', DELIMITER '|')"
                     rows
                     {:null "NULL"
                      :sep \|})
    ;; 1000
    

    Copy rows as a binary payload with custom type hints:

    (pg/copy-in-rows conn
                     "COPY foo (id, name, active, note) from STDIN WITH (FORMAT BINARY)"
                     rows
                     {:format :bin
                      :oids {0 oid/int2 2 oid/bool}})
    ;; 1000
    

    COPY IN maps

    Often, we deal not with plain rows but maps. The copy-in-maps function acts but copy-in-rows but accepts a sequence of maps. Internally, all the maps get transformed into rows. To transform it properly, the function needs to know the order of the keys.

    The funtion accepts a connection, a SQL expression, a sequence of maps and a sequence of keys. Internally, it produces a selector from the keys like this: (apply juxt keys) which gets applied to each map.

    One more thing about copying maps is, that the :oids parameter is a map like {key => OID}.

    An example of copying the maps in CSV. Pay attention that the second map has extra keys which are ignored.

    (pg/copy-in-maps conn
                     "copy foo (id, name, active, note) from STDIN WITH (FORMAT CSV)"
                     [{:id 1 :name "Ivan" :active true :note "aaa"}
                      {:aaa false :id 2 :active nil :note nil :name "Juan" :extra "Kek" :lol 123}]
                     [:id :name :active :note]
                     {:oids {:id oid/int2}
                      :format :csv})
    

    Another example where we copy maps using binary format. The :oids map has a single type hint so the :id fields get transformed to int2 but not bigint which is default for Long values.

    (pg/copy-in-maps conn
                     "copy foo (id, name, active, note) from STDIN WITH (FORMAT BINARY)"
                     maps
                     [:id :name :active :note]
                     {:oids {:id oid/int2}
                      :format :bin})
    
  • Языки диаграмм

    Есть языки, которые описывают схемы и диаграммы. Например, пишешь что-то такое:

    Потом натраливаешь на файл программу и получается картинка:

    Казалось бы, все автоматически, красиво, удобно. Но нет.

    Когда я верстал первую книгу в Латехе, то использользовал для графиков пакет TikZ. Там то же самое: схематично описываешь сущности, а пакет их рендерит. Исходник:

    и результат:

    Во второй книге я отказался от TikZ по следующим причинам.

    Во-первых, синтаксис диаграмм забывается очень быстро. Если работаешь с ними каждый день, то проблемы не возникает. Однако чаще всего ты рисуешь диаграмму раз в полгода и не трогаешь ее. Когда открываешь файл опять, нужно вспоминать все заново. Откуда и куда вести стрелку, нужна ли точка с запятой, ставить => или ->> для уголка и все в том же духе. Каждый раз как в первый раз.

    Далее, дьявол кроется в деталях. Помните правило 20/80? Большую часть диаграммы вы покроете за 20 процентов времени, и возникнет иллюзия — какой я молодец, что использую язык! Быстро управился. А потом провозитесь три часа в попытке сдвинуть первый прямоугольник влево, а второй — вправо. И рамка будет кривая. И позиционирование будет неудобное. Захочется сдвинуть вручную, но нелья — у нас же не Фотошоп или Иллюстратор, а код.

    Наконец, все эти языки не имеют понятия о восприятии. Когда я рисую диаграмму, то думаю о пользователе: насколько ему удобно. Поэтому отталкиваюсь от самых важных элементов; насколько хорошо они контрастируют со второстепенным; насколько легко проследить процесс; нет ли объектов, которые этому мешают.

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

    Когда я сел за вторую книгу, то пытался сделать схемы в TikZ как раньше. Но обнаружил, что синтаксис забыт начисто, а вспоминать не хочется. В итоге нарисовал в Monodraw — программе ASCII-диаграмм — и экспортировал в SVG. Это проще и быстрее. Если нужно подвинуть фигуру на клетку влево, я сделаю это в один клик. Не нужно вспоминать, какое свойство отвечает за сдвиг и в каких единицах оно измеряется.

    Против Monodraw можно сказать только то, что она под Мак. Для командной работы, возможно, не подойдет. С другой стороны, пусть в команде будет один человек, который отвечает за диаграммы. Если их редактируют все подряд, это беда.

    Экстраполируя, можно сказать, что языки построения схем — блажь. Их любит “серьезный” бизнез, где вещи внедряют лишь затем, что так принято. Хорошим примером служит UML — циклопическая абстракция о том, что и как описывать. В нее вбухали невероятно много денег и времени; всерьез считали, что если отобразить классы диаграммой, логика программы станет очевидной. И что — кто-то пользуется UML сегодня? Доводилось ли кому-то посмотреть на UML и сказать: точно, я все понял?

    Наконец, если программы для диаграмм сводят с ума, остается надежное средство — рисовать руками. Берете маркеры и рисуете на доске то, что нужно. Затем снимок → коррекция → красивая картинка.

    Как-то раз я делал презентацию об HTMX для команды, и сама мысль о какой-то программе вызывала дрожь. В итоге нарисовал на доске и отфотал. Пара слайдов из презентации:

    Местами кривовато, но я и не особо старался.

    А можно заморочится и нарисовать на бумаге. Например, Bob Nystrom в своих знаменитых книгах Crafting Interpreters и Game Programming Patterns рисовал схемы гелевой ручкой на бумаге, а потом сканировал. Просто сказка:

    Какой итог можно подвести? Мне кажется, такой:

    • языки диаграмм — прохладная затея, потому что машина не понимает, что первично, а что вторично, что удобно пользователю, а что нет;

    • языки диаграмм — идеальный пример правила 80/20. Быстро получаешь почти готовый результат, но на доводку уходит много времени;

    • откройте исходник диаграммы через полгода — будет трудно вспомнить, что и как поправить;

    • не бойтесь ручного труда — рисунки на доске или бумаге по-прежнему смотрятся отлично.

  • PG docs, part 6. SSL

    (This is a new documentation chapter from the PG project.)

    ToC

    In this chapter:

    The recent update of PG introduces SSL support. Install the newest version of PG as follows:

    Lein:

    [com.github.igrishaev/pg-client "0.1.8"]
    [com.github.igrishaev/pg-ssl "0.1.8"] ;; optional, for a custom SSL context
    

    Deps:

    {com.github.igrishaev/pg-client {:mvn/version "0.1.8"}
     com.github.igrishaev/pg-ssl {:mvn/version "0.1.8"}}
    

    Setup

    The are two ways to set up an SSL connection to the database. The first, and simple, is to set the :ssl? boolean flag to true and just connect:

    {:host "some.cloud.host.com"
     :port 5432
     :ssl? true
     ...}
    

    In this case, the entire SSL pipeline is held by Java. It tries to find the corresponding keys and certificates using the standard KeyStore and TrustStore which you configure on your own.

    The second and more flexible way is to provide a custom SSL context to the connection map. It must be an instance of the javax.net.ssl.SSLContext class. Building such an instance from scratch is quite miserable though. To make your life easier, there is a thin wrapper on top of the great Less Awful SSL library that takes a map of certificates and keys and returns an instance of SSLContext. Since it requires a third-party library, it’s shipped as a standalone package pg-ssl. Add it to the project:

    [com.github.igrishaev/pg-client "0.1.8"] ;; lein
    ;; or
    {com.github.igrishaev/pg-ssl {:mvn/version "0.1.8"}} ;; deps
    

    Now pass the :ssl-context parameter in addition to :ssl?. It’s a map with the string keys :key-file, :cert-file, and :ca-cert-file:

    (ns foo.bar
      (:require
        [pg.ssl :as ssl]))
    
    {:host "some.cloud.host.com"
     :port 5432
     :ssl? true
     :ssl-context
     (ssl/context {:key-file "/path/to/client.key"
                   :cert-file "/path/to/client.crt"
                   :ca-cert-file "/path/to/root.crt"})}
    

    The :ca-cert-file parameter might be missing if just :key-file and :cert-file are enough.

    (ssl/context {:key-file "/path/to/client.key"
                  :cert-file "/path/to/client.crt"})
    

    EDN Config

    Often, we store the configuration in an EDN file. To declare SSL context there, prepend it with a reader tag called #pg/ssl-context:

    {:ssl? true
     :ssl-context #pg/ssl-context {:key-file ...}}
    

    When reading EDN, pass that tag to the :readers map as follows:

    {'pg/ssl-context pg.ssl/ssl-context-reader}
    

    The tag wraps the map with a function that builds the SSLContext from it.

    Some cloud platforms give you only the root certificate. In that case, generate both the client key and the the client certificate on your own using the root certificate. Something like:

    umask u=rw,go= && openssl req -days 365 -new -nodes -subj '/C=US/ST=Test/L=Test/O=Personal/OU=Personal/emailAddress=test@test.com/CN=test' -keyout client.key -out client.csr
    umask u=rw,go= && openssl x509 -days 365 -req  -CAcreateserial -in client.csr -CA root.crt -CAkey server.key -out client.crt
    

    When generating the certificates, pay attention to the CN field which is “test” in our case. In terms of PostgreSQL, it should match the database user. Different users will have different certificates.

    Testing

    The SSL functionality is difficult to test in Docker so I’ve got to run a native instance. Here is a brief setup.

    • In postgresql.conf, enable the ssl parameter and specify paths to the files:
    ssl=on
    ssl_cert_file='/Users/ivan/work/pg/certs/server.crt'
    ssl_key_file='/Users/ivan/work/pg/certs/server.key'
    ssl_ca_file = '/Users/ivan/work/pg/certs/root.crt'
    
    • In pg_hba.conf, enable the “cert” validation type for SSL connections:
    hostssl all all all cert
    

    Finally, create a user with a name that matches the CN field:

    create user <CN-field> with password '******';
    
  • SQL

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

    SQL выручал меня не только по работе. У нас в ЖК стоят шлагбаумы, и однажды я запросил логи, чтобы посмотреть, кто и когда и открывает. Мне скинули несколько страшных CSV. Разбирать их Экселе было адом, и я загнал в Постгрес и построил отчеты по всем мыслимым критериям.

    Импорт:

    COPY logs(time, account, type, ident, status, barrier)
    FROM '/Users/ivan/work/barriers/65_Ш6.csv'
    DELIMITER ';' CSV HEADER;
    

    Статусы:

    select distinct status from logs
    
    | status
    |---------------------------------------
    | Добавлен
    | Отказ (пульт)
    | Отказ (нет в базе или ошибка кнопка)
    | Открыто (карта в базе)
    | Допуск (пульт)
    ...
    

    Типы доступа:

    select distinct type from logs
    
    | type
    |--------------
    | Событие
    | Карта, метка
    | Звонок
    | Удаление
    ...
    

    Общее число открываний в разрезе помещений:

    select account, count(ident) as count from logs
    where account <> 'Нет в базе'
    and time > '2022-05-01 00:00:00'
    and status in ('Допуск (телефон)', 'Допуск (пульт)')
    and type in ('PP', 'Звонок')
    group by account
    order by count desc
    limit 50
    

    Данные по конкретной квартире по дням:

    select date_trunc('day', time) as day, count(ident) from logs
    where account = 'ООО "Рога и копыта"'
    and time > '2022-05-01 00:00:00'
    and status in ('Допуск (телефон)', 'Допуск (пульт)')
    and type in ('PP', 'Звонок')
    group by day
    order by day desc
    limit 50
    

    Число телефонов и пультов на помещение:

    select account, count(distinct ident) as amount from logs
    where account <> 'Нет в базе'
    and time > '2022-05-01 00:00:00'
    and status in ('Допуск (телефон)', 'Допуск (пульт)')
    group by account
    order by amount desc
    limit 100
    

    Среднее число открываний в день:

    select account, count(ident) / count(distinct date_trunc('day', time)) as per_day
    from logs
    where account <> 'Нет в базе'
    and time > '2022-05-01 00:00:00'
    and status in ('Допуск (телефон)', 'Допуск (пульт)')
    and type in ('PP', 'Звонок')
    group by account
    order by per_day desc
    limit 100
    

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

    Из недавнего: понадобилось считать рабочие часы по неделям. Что в Экселе, что Гугле это оказался адский ад, потому что тип time не переполняется. То есть если сложить 7 + 7 + 7 + 7 + 7 часов (пять рабочих дней), получим 11 часов, потому что 24 уйдут в перекрут. Просидел час, думая, как это решить, и так и не решил.

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

    create table work (date date, hours interval);
    

    Импорт:

    \copy work from /Users/ivan/Downloads/foobar.csv
      with (format csv);
    

    Удаляем пустые интервалы:

    delete from work where hours is null;
    

    Всего часов:

    select sum(hours) from work;
    

    Отчет по неделям:

    select date_trunc('week', date) as week, sum(hours)
    from work
    group by week
    order by week;
    

    И все это — не выходя из консоли psql. Представьте, какой гемор был бы построить это в Экселе, Гугле или даже на языке программирования вроде Питона. Там с одним только парсингом дат и времени наешься, а тут все из коробки.

    Можно сбросить все отчеты в CSV одной командой, чтобы пересохранить в Excel и отправить кому-то по работе.

    Словом, одно из немногих утешений сегодня — это SQL/Postgres. Надежный как скала, полезный, простой и востребованный.

  • Выпадашки на StackOverflow

    Я как-то уже писал: нам не везет с дизайнерами. Всякие Гуглы и StackOverflow берут ребят с выпадашечным мышлением. Каждый раз, чтобы донести информацию до пользователя, дизайнер ставит выпадашку. При этом все равно, что выпадашка Васи наползает на выпадашку Пети. Это не его проблема, он добавил, а остальное его не касается.

    Сегодняшняя страница Stack Overflow как я ее вижу. Слой с бессмысленным текстом наползает на какое-то дерьмо про искусственный интеллект. Выпадашка на выпадашке. Интересно, как в SO устроена работа дизайнеров и контроль качества? Это же Провал Провалыч, и причина одна — несогласованность между дизайнерами.

    Нельзя так.

  • Слак устарел

    Этим утром Слак порадовал вдвойне: сообщил, что устарел не только он, но и моя операционная система.

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

    Всё еще надеясь на лучшее, скачал последний Слак, но увы — программа не запускается, нужна версия Мака не ниже чего-то там.

    “Устаревание” программы ещё можно понять: ленивые жопы не хотят поддерживать то, что сами же написали. Плохо, но не критично. А вот с операционкой я не пойму. Во-первых, ваш Слак — это Хром и Node.js, что там может требовать новой операционки? Обычный Хром не требует обновления Мака, а вы что, какие-то особенные?

    Во-вторых, допускаю, что в новой операционке есть какая-то лабуда, но вопрос — она действительно нужна в повседневной работе? Без неё не напишешь сообщение и не позвонишь? А как раньше без неё жили?

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

    Не нравится? А тебе, значит, можно так делать — отрубить мне программу для общения по работе? Ты хотя бы представил мысленно, что сделает пользователь, когда увидит это сообщение?

    Эх, ребята из Слаки! Откуда вас понабрали? Нет у вас ни этики, ни эмпатии, ни мозгов.

  • Оплата в Сбере

    Кто пользуется Сбером, объясните, пожалуйста. Почему, чтобы заплатить за музыкалку, нужно пять экранов? Не один, не два, а пять? При этом каждый экран повторяет прошлый на 90%: это реквизиты, всякие КБК, ОКТМО и прочая хрень, интересная только бухгалтеру.

    Вот я накидал скриншотов:

    Первый экран — реквизиты и кнопка “продолжить”.

    Второй экран — те же реквизиты и выбор документа.

    Третий экран — опять реквизиты(!) и серия/номер документа.

    Четвёртый экран — РЕКВИЗИТЫ и сумма платежа.

    Пятый экран — РЕКВИЗИТЫ и выбор счета.

    Та же беда с коммуналкой, всякими карате, рисовалками и прочее. В коммуналке, кстати, еще +3 экрана на ввод показаний с счётчиков.

    Не многовато ли?

  • Извинения за неудобства

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

    Конечно, нашему “ПАО ТНС Энерго Воронеж” (что за бредовое название) не знакомы эти правила. Что с них взять? Но давайте поучимся чужих ошибках.

    В этом месяце ребята лоханулись: разослали квитанции с битыми QR-кодами. Ну, бывает, вдруг Node.js-программист обновил package.json? Важно то, что с этим сделает руководство. А оно пишет: берите коды из прошлых писем и примите извинения. Но в кодах зашито назначение платежа, и Сбербанк не дает его поменять. А платеж по реквизитам это просто ад: у “ПАО ТНС” сто филиалов, и какой выбрать — неизвестно.

    Конечно, надо было сгенерить новые квитанции и разослать вместо сломанных. Простое правило: кто накосячил, тот и исправляет.

    Повторюсь, к ПАО ТНС Энерго Воронеж претензий особо нет. Большая фирма, бюрократия, и ничего кроме “примите извинения” они не могут. Для нас главное — не допустить такого в своей работе.

  • Ввод по маске

    Каждый мамкин фронтендер знает: если в форме есть поле телефона, ставим плагин jQuery под названием Masked Input и не паримся. С ним нельзя ввести буквы, скобки, дефисы и прочий мусор.

    Но мамкин фронтендер не знает: если вставить в поле “89623289677” (скажем, из Экселя или менеджера паролей), то всратый плагин поместит восьмерку после +7 и отбросит последнюю цифру. Результат будет как на второй картинке.

    То есть всего-то испортили данные, но никого это не волнует. Подумаешь, не придет смс, не дозвонится курьер, не выдадут посылку. Фронтендеру не до этих мелочей.

    Недаром говорят: благие намерения ведут в ад. Хотели сделать удобно на фронте, в итоге добавили баг.

Страница 16 из 82