experiments
August 26, 2023

Неочевидное но вероятное. Часть 2: Julia

Продолжаем изучать необычные и малоизвестные языки программирования. В этот раз расскажу о Julia — очень молодом по меркам отрасли языке, который появился на свет в 2015 м году (моложе моего кота), но подающим большие надежды.

Неверьте, нормальным людям этот язык тоже будет полезен

Язык и фреймворк

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

Julia подается как «язык будущего», поэтому:

Julia is dynamically typed, feels like a scripting language, and has good support for interactive use.

Да, как в вашем любимом петоне или руби — тоже есть интерактивная консоль, через которую и происходят «основные события», поскольку управление пакетами также реализовали через нее.

Сразу расскажу про особенности тестовой среды, для всего описанного в статье я использовал FreeBSD 13.2, для которой также есть готовая бинарная сборка (какой сюрприз).

Самая популярная IDE для Julia называется Juno, которая уже успела устареть и перековаться в плагин для VSCode, который я и использовал, установив на версию VSCode для FreeBSD.

Теперь про сам язык.

Начнем с того что понравилось прям сразу: «As with variables, Unicode can also be used for function names»:

julia> ∑(x,y) = x + y
∑ (generic function with 1 method)

julia> ∑(2, 3)
5

Можно сказать самый первый признак чего-то претендующего на футуристичность в ИТ — изначальная и глубокая поддержка юникода.

Но конечно это мелочи по сравнению с вот таким:

Function composition and piping

Function composition is when you combine functions together and apply the resulting composition to arguments. You use the function composition operator () to compose the functions, so (f ∘ g)(args...) is the same as f(g(args...)).

For example, the sqrt and + functions can be composed like this:

julia> (sqrt ∘ +)(3, 6)
3.0

Как тебе такое Элон Маск?

Не знаю появится ли подобное в моей любимой Java, но это отличный вариант избавиться наконец от гор ((( и ))) при подобных вложенных вызовах.

Promotion

Promotion refers to converting values of mixed types to a single common type.

julia> promote(1, 2.5, 3, 3//4)
(1.0, 2.5, 3.0, 0.75)

Казалось бы мелочь, полная ерунда да? А теперь вспомните весь гемморой с конвертацией в числа с плавающей точкой и количество вопросов по теме на каком-нибудь Stackloverflow.

Generator Expressions

Comprehensions can also be written without the enclosing square brackets, producing an object known as a generator.

For example, the following expression sums a series without allocating memory:

julia> sum(1/n^2 for n=1:1000)
1.6439345666815615

Ключевое тут "without allocating memory" да.

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

The @threads Macro

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

Для начала пример с решением многопоточного суммирования чисел от 1 до миллиона на Julia, с использованием этого макроса.

Buffers that are specific to the task may be used to segment the sum into chunks that are race-free. Here sum_single is reused, with its own internal buffer s, and vector a is split into nthreads() chunks for parallel work via nthreads() @spawn-ed tasks.
julia> function sum_multi_good(a)
           chunks = Iterators.partition(a, length(a) ÷ Threads.nthreads())
           tasks = map(chunks) do chunk
               Threads.@spawn sum_single(chunk)
           end
           chunk_sums = fetch.(tasks)
           return sum_single(chunk_sums)
       end
sum_multi_good (generic function with 1 method)

julia> sum_multi_good(1:1_000_000)
500000500000

Тоже самое на Java/C++/C# займет пару экранов кода, который еще далеко не факт что заработает правильно.

Multicast

Julia supports multicast over IPv4 and IPv6 using the User Datagram Protocol (UDP) as transport.

Что честно говоря было достаточно неожиданно для 21 века.

Вот так например выглядит отправка, причем на IPv6 (!):

To transmit data over UDP multicast, simply send to the socket. Notice that it is not necessary for a sender to join the multicast group.
using Sockets
group = Sockets.IPv6("ff05::5:6:7")
socket = Sockets.UDPSocket()
send(socket, group, 6789, "Hello over IPv6")
close(socket)

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

Примеры кода

Вот достаточно простой пример, рисующий фрактал символами в консоли:

function mandelbrot(a)
    z = 0
    for i=1:50
        z = z^2 + a
    end
    return z
end

for y=1.0:-0.05:-1.0
    for x=-2.0:0.0315:0.5
        abs(mandelbrot(complex(x, y))) < 2 ? print("*") : print(" ")
    end
    println()
end

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

Результат работы выглядит вот так:

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

using LinearAlgebra: norm
function collision(p::Particle, e::Ellipse)
    dotp = dot(p.vel, normalvec(e, p.pos))
    dotp ≥ 0.0 && return nocollision()

    a = e.a; b = e.b
    pc = p.pos - e.c
    μ = p.vel[2]/p.vel[1]
    ψ = pc[2] - μ*pc[1]

    denomin = a*a*μ*μ + b*b
    Δ² = denomin - ψ*ψ
    Δ² ≤ 0 && return nocollision()
    Δ = sqrt(Δ²); f1 = -a*a*μ*ψ; f2 = b*b*ψ # just factors
    I1 = SV(f1 + a*b*Δ, f2 + a*b*μ*Δ)/denomin
    I2 = SV(f1 - a*b*Δ, f2 - a*b*μ*Δ)/denomin

    d1 = norm(pc - I1); d2 = norm(pc - I2)
    return d1 < d2 ? (d1, I1 + e.c) : (d2, I2 + e.c)
end

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

Example that highlights the extendability and intuition Julia brings on the table.

Made by George Datseris.

Я после определенных изысканий все-таки смог его собрать и запустить на FreeBSD, выглядит вот так:

Хотя конечно не вполне понимаю суть, но тем не менее.

Как вы уже наверное догадались, Julia ориентирована в первую очередь на научную работу и ученых.

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

DynamicalBilliards is an easy-to-use, modular and extendable Julia package for dynamical billiards in two dimensions. It is part of JuliaDynamics, an organization dedicated to creating high quality scientific software.

Но поскольку я все же больше инженер, то и задачи решаю куда более обыденные: веб, подключение к СУБД, REST да JSON.

Все такое пролетарское вообщем.

Поэтому «теста ради и практики для» сваял простой проект «Гостевой книги» со всеми стандартными для типичного разработчика вещами.

Так сказать для оценки и сопоставления с более обыденными языками.

Проект «Упоротая гостевая»

Я решил все же не делать совсем все вручную а использовать что-то готовое. Код как обычно выложен на гитхаб.

Веб-фреймворк

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

Oxygen is a micro-framework built on top of the HTTP.jl library. Breathe easy knowing you can quickly spin up a web server with abstractions you're already familiar with.

Ну вы поняли вообщем:

  • Straightforward routing
  • Auto-generated swagger documentation
  • Out-of-the-box JSON serialization & deserialization (customizable)
  • Type definition support for path parameters
  • Built-in multithreading support
  • Built-in Cron Scheduling (on endpoints & functions)
  • Middleware chaining (at the application, router, and route levels)
  • Static & Dynamic file hosting
  • Route tagging
  • Repeat tasks

Что еще нужно для счастья старого офицера спрашивается?

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

Хотя если вы читали мои предыдущие статьи — сие предупреждение новостью не будет.

База данных

Для примера я взял обычную MariaDb (в девичестве MySQL), которую для FreeBSD еще надо установить. Хотя врядли с этим возникнут проблемы, если уж вы дошли до этого места.

Разумеется что для тестового проекта я ограничусь минимумом, поэтому будет лишь одна таблица да пара полей:

CREATE DATABASE julia_test CHARACTER SET utf8 COLLATE utf8_general_ci;

USE julia_test;

CREATE TABLE posts (
id INT NOT NULL AUTO_INCREMENT,
title VARCHAR(512) NOT NULL,
author VARCHAR(255) NOT NULL,
message VARCHAR(1024) NOT NULL,
created_dt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,                   
PRIMARY KEY(id));

Запускаете MariaDb локально и цепляетесь:

mysql -u root -h 127.0.0.1 -p

Если все настроено правильно — появится консоль базы данных, в которую и вбиваете SQL-скрипт выше.

Проект

Забираем проект из github:

git clone https://github.com/alex0x08/julia-guestbook.git

Заходим в папку проекта и запускаем julia:

julia  --project=. 

Естественно что сама Julia при этом должна быть в переменной PATH.

Теперь нужно выполнить прекомпиляцию, которая запустит скачивание зависимостей.

Нажмите "]" , откроется режим работы с пакетами:

Введите «precompile», запустится скачивание зависимостей:

А затем и компиляция зависимостей:

Сам тестовый проект запускается двумя способами, первый из REPL:

Из консоли:

julia --threads 4 --project=. src/МояУпоротаяГостевая.jl 

Настройка подключения к базе находится в файле LocalPreferences.toml:

# Типа настойки.
[MyGbSettings]
dbserver = "127.0.0.1"
dbuser = "root"
dbpassword = "qwerty"
dbdb = "julia_test"

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

JULIA_DEBUG=МояУпоротаяГостевая julia --threads 4 --project=. src/МояУпоротаяГостевая.jl

Визуально это будет сразу:

После запуска открываете http://127.0.0.1:8080 в вашем любимом браузере. Выглядит это вот так:

Интерфейс Swagger будет доступен по адресу http://127.0.0.1/docs, выглядит вот так:

Теперь самое интересное, код проекта:

#
# Тестовый проект "Упоротая гостевая"
# Написан ради статьи в блоге https://teletype.in/@alex0x08
# Для большего фана, все что можно было локализовано.
#
module МояУпоротаяГостевая
# используемые библиотеки
using Oxygen,SwaggerMarkdown,HTTP,StructTypes,MySQL,Dates,Preferences,Pkg
using Base: UUID
# эта военная хитрость необходима чтобы обойти ограничение TOML на ANSII символы в ключах.
# Фейковый UUID пакета, определяющийся через [extras] секцию
Preferences.main_uuid[] = UUID("16e4e860-d6b8-5056-a518-93e88b6392ae")
# DTO с настройками подключения к базе
struct НастройкаПодключения
    хост::String
    база::String
    юзер::String
    пароль::String
    function НастройкаПодключения()
        new(@load_preference("dbserver"),
            @load_preference("dbdb"),
            @load_preference("dbuser"),
            @load_preference("dbpassword"))     
    end    
end    
#
# Типа DTO/Entity для записи в гостевой
struct Пост
    # ID записи, автогенерируется
    id::Int32 
    # заголовок
    title::String
    # автор
    author::String
    # текст сообщения
    message::String
    # дата создания
    createdDt::Dates.DateTime
    # свой конструктор, для обработки пустого ID, когда это DTO используется для добавления нового поста
    function Пост(id, title, author,message,createdDt)
        new(id != nothing ? id : 0, title, author, message,createdDt != nothing ? createdDt : Dates.DateTime(0))
    end    
end

# Регистрация нашего DTO для автоматической (де)сериализации через библиотеку JSON3
StructTypes.StructType(::Type{Пост}) = StructTypes.Struct()
# подключение к базе
подключение = nothing

# инициализация модуля (как в петоне)
function __init__()
	setup()
end
# инициализация Упоротой Гостевой
function setup() 
    @info "инициализация.."
    @debug "упоротая отладка включена, поздравляю!"
    # Да, тут тоже есть Swagger
    @swagger """
        /api/records:
            get:
                description: Отдает все посты в гостевой
                responses:
                    '200':
                        description: Типа все ОК.
    """
    @get "/api/records" function(req::HTTP.Request)
        @debug "вызов API получения всех постов"
        # выходной массив с постами
        посты = Пост[]
        # получить выборку
        курсор = DBInterface.execute(МояУпоротаяГостевая.подключение, 
                "select p.* from posts p order by p.created_dt desc limit 500") 
        # формируем запись и пихаем в массив
        for запись in курсор
            push!(посты, Пост(запись[1], запись[2], запись[3],запись[4],запись[5]))
        end    
        # отдаем массив DTO, который будет автоматически сериализован в JSON
        return посты
    end
    @swagger """
        /api/delete:
            get:
                description: Удаляет запись гостевой
                responses:
                    '200':
                        description: Типа все ОК.
    """
    @post "/api/delete" function(req::HTTP.Request)
        @debug "вызов API удаления поста"
        params=queryparams(req)
        # проверка "notin" - ключ id не в Dict
        if ("id" ∉ keys(params))
            return HTTP.Response(400, "Параметр ID обязателен")        
        end    
        recordId = params["id"]
        if (length(recordId)<1 || length(recordId)>500)
            return HTTP.Response(400, "Параметр ID какой-то кривой")      
        end        
        DBInterface.execute(МояУпоротаяГостевая.подключение, "delete from posts where id = $(recordId)") 
        HTTP.Response(200, "Запись удалена")    
    end
    @swagger """
        /api/add:
            get:
                description: Добавляет или обновляет запись гостевой
                responses:
                    '200':
                        description: Типа все ОК.
    """
    @post "/api/add" function(req::HTTP.Request)
        @debug "вызов API добаления/обновления поста"
        пост = nothing
        # десериализуем из строки JSON в теле запроса в struct
        try
            пост = json(req, Пост)
        catch error 
            @error "Ошибка разбора JSON: " exception=(error, catch_backtrace())
            return HTTP.Response(500, "Упоротый сервер совершил ошибку")
        end 
        if (length(пост.title)<3 || length(пост.author)<3 || length(пост.message)<3)
            return HTTP.Response(400, "Недостаточно данных для создания поста")
        end     
        # если не был указан id то создаем запись
        if (пост.id>0)
            курсор = DBInterface.execute(МояУпоротаяГостевая.подключение, 
                "UPDATE posts SET title='$(пост.title)', author='$(пост.author)', 
                    message='$(пост.message)', createdDt = now() WHERE id=$(пост.id)")
        # если указан - обновляем
        else
            курсор = DBInterface.execute(МояУпоротаяГостевая.подключение, 
                "INSERT INTO posts (title, author, message) VALUES ('$(пост.title)','$(пост.author)','$(пост.message)')")
        end
        # ID созданной/обновленной записи
        идЗаписи = DBInterface.lastrowid(курсор)
        # вытаскиваем обновленную запись
        курсор2 = DBInterface.execute(МояУпоротаяГостевая.подключение, 
                "select p.* from posts p where p.id = $(идЗаписи)") 
        # получаем сами данные из курсора
        запись = first(курсор2)
        # возвращаем DTO, которое будет автоматически превращено в JSON
        return Пост(запись[1], запись[2], запись[3],запись[4],запись[5])
    end

    # отдача страницы по-умолчанию
    get("/") do
        return file("content/gb.html")
    end
    # иконка
    get("/favicon.ico") do
        return file("content/favicon.ico")
    end

    # метаданные для сваггера
    info = Dict("title" => "API для упоротой гостевой", "version" => "1.0.0")
    openApi = OpenAPI("3.0", info)
    swagger_document = build(openApi)
    # генерация документации
    mergeschema(swagger_document)

end
function isREPL()
    abspath(PROGRAM_FILE) != @__FILE__
end     
# отдельная функция для запуска сервера, чтобы вызывать через REPL
function runserver()
    @info "запуск упоротой гостевой"
    # отдача статики из папки "content", будет отдаваться по пути "/static" 
    staticfiles("content", "static")
    # загрузка настроек подключения
    настройка = НастройкаПодключения()
    # подключение к СУБД
    МояУпоротаяГостевая.подключение = DBInterface.connect(MySQL.Connection,
                    настройка.хост, 
                    настройка.юзер, 
                    настройка.пароль, db=настройка.база)
    @info "Подключение к СУБД установлено"
    # запуск HTTP сервера
    if isREPL()
        serve()
    else
        serveparallel()
    end
end
# если запуск не через REPL - считаем себя программой и запускаемся
if !isREPL()
    # отдельно вызов настройки
    setup()
    # запуск 
    runserver()
end

end # конец модуля

Еще есть небольшая часть на Javascript, HTML шаблон и минифреймворк на CSS, но они настолько банальны что не нуждаются в описании.