Иногда в веб-приложениях возникает необходимость сделать какое-то длительное действие, которое может привести к остановке эвент-лупа и, соответственно, отказу в обслуживании на время обработки запросов.
Для решения этой проблемы в приложениях на Mojolicious можно использовать комбинацию Promise и Subprocess в виде модулей Mojo::Promise и Mojo::IOLoop.
Важный нюанс, если ваше длительное действие инзначально асинхронное и не нагружает процессор, то смысла в использовании subprocess нет, т.к. subprocess спавнится через fork, а это может быть относительно дорогим удовольствим. Так же у subprocess нет встроенных методов для ограничения нагрузки. Так что если CPU-Bound задачи вам надо решать часто, в больших количествах и, желательно, не устраивая DOS-атаку свой сервер, то лучше использовать очереди и воркеров. Хотя, конечно никто не мешает накидать очередь на промисах и вот это всё.
Итак, для примера создадим простейшее приложение на Mojo::Lite которое будет принимать картинку от пользователя и делать из нее набор картинок в разных разрешениях.
#!/usr/bin/env perl
use strict;
use warnings;
use utf8;
use feature ':5.10';
use Mojolicious::Lite;
use Imager;
use File::Spec;
my $log = Mojo::Log->new;
my @scales = qw/640 800 1024 2048/;
my $static_dir = app->static->paths->[0];
my %scales_paths = map { $_ => File::Spec->catfile( $static_dir, $_ ) } @scales;
# Upload form in DATA section
get '/' => 'form';
# Multipart upload handler
post '/upload' => sub {
my $c = shift;
# Process uploaded file
return $c->redirect_to('form') unless my $image = $c->param('image');
my $size = $image->size;
my $name = $image->filename;
mkdir $static_dir unless -e $static_dir;
my $image_path = File::Spec->catfile( $static_dir, $name );
$image->move_to($image_path);
my $imager = Imager->new();
$imager->read( file => $image_path ) or die $imager->errstr;
for my $scale (@scales) {
mkdir $scales_paths{$scale}
unless -e $scales_paths{$scale}; #check that folder is exists
my $scaled = $imager->scale( xpixels => $scale );
$scaled->write(
file => File::Spec->catfile( $scales_paths{$scale}, $name ) )
or die $scaled->errstr;
}
$c->render( text => "Thanks for uploading $size byte file $name." );
};
app->start;
__DATA__
@@ form.html.ep
<!DOCTYPE html>
<html>
<head><title>Upload</title></head>
<body>
%= form_for upload => (enctype => 'multipart/form-data') => begin
%= file_field 'image'
%= submit_button 'Upload'
% end
</body>
</html>
Основной код приложения честно сперт из официального гайда. С добавлением кода обработки загруженных картинок. Конечно можно было ограничится банальным sleep 5
, но, на мой взгляд, это скучно. Хоть и дает в разы меньше кода в примере.
Если запустить этот пример и загрузить картинку, то можно заметить что между нажатием кнопки Upload и получением результата проходит достаточно существенное время (на моем i5 около двух секунд для картинки в 12Mpix). Все это время приложение не отвечает на запросы снаружи, что может быть довольно неприятно.
Первое и самое простое что можно сделать, если нам нет необходимости возвращать результат работы прямо сейчас, это просто обернуть ресурсоемкий код в subprocess.
Изменим метод для аплоада картинок чтобы он выглядел следующим образом:
post '/upload' => sub {
my $c = shift;
# Process uploaded file
return $c->redirect_to('form') unless my $image = $c->param('image');
my $size = $image->size;
my $name = $image->filename;
mkdir $static_dir unless -e $static_dir;
my $image_path = File::Spec->catfile( $static_dir, $name );
$image->move_to($image_path);
save_image($name, $image_path);
$c->render( text => "Thanks for uploading $size byte file $name." );
};
Mojo::IOLoop->start;
app->start;
sub save_image {
my $image_name = shift;
my $image_path = shift;
my $imager = Imager->new();
$imager->read( file => $image_path ) or die $imager->errstr;
Mojo::IOLoop->subprocess(
sub {
my $subprocess = shift;
for my $scale (@scales) {
mkdir $scales_paths{$scale}
unless -e $scales_paths{$scale}; #check that folder is exists
my $scaled = $imager->scale( xpixels => $scale );
$scaled->write(
file => File::Spec->catfile( $scales_paths{$scale}, $image_name )
) or die $scaled->errstr;
}
$log->debug("done");
return 1;
},
sub {
my ( $subprocess, $err, @results ) = @_;
$log->error("Subprocess error: $err") and return if $err;
}
);
}
Здесь мы вынесли код сохранения картинки в отдельную подпрограмму, а заодно обернули в subprocess. Кстати, сохранять вотчер сабпроцесса не требуется. При создании он сам прописывается в IOLoop и живет там.
Теперь можно заметить, что ответ в браузер выдается практически сразу, а строчка done
в логе появляется позже ответа [debug] 200 OK (0.201017s, 4.975/s)
. В списке процессов тоже можно будет наблюдать появление чайлда у нашего процесса приложения. Так работает subprocess.
Ок, с простым запуском разобрались, теперь посмотрим что делать если мы хотим выдать клиенту ответ по результату работы, но при этом хотим обслуживать других клиентов. Здесь нам поможет Promise в виде модуля Mojo::Promise
, который реализует спецификацию Promise/A+.
Приведем код к виду:
post '/upload' => sub {
my $c = shift;
# Process uploaded file
return $c->redirect_to('form') unless my $image = $c->param('image');
my $size = $image->size;
my $name = $image->filename;
mkdir $static_dir unless -e $static_dir;
my $image_path = File::Spec->catfile( $static_dir, $name );
$image->move_to($image_path);
my $promise = save_image( $name, $image_path );
$promise->then(
sub {
$c->render( text => "Thanks for uploading $size byte file $name." );
}
)->wait;
};
sub save_image {
my $image_name = shift;
my $image_path = shift;
my $imager = Imager->new();
$imager->read( file => $image_path ) or die $imager->errstr;
my $promise = Mojo::Promise->new;
Mojo::IOLoop->subprocess(
sub {
my $subprocess = shift;
for my $scale (@scales) {
mkdir $scales_paths{$scale}
unless -e $scales_paths{$scale}; #check that folder is exists
my $scaled = $imager->scale( xpixels => $scale );
$scaled->write( file =>
File::Spec->catfile( $scales_paths{$scale}, $image_name )
) or die $scaled->errstr;
}
$log->debug("done");
return 1;
},
sub {
my ( $subprocess, $err, @results ) = @_;
$promise->reject("Subprocess error: $err @results") if $err;
$promise->resolve( 1, "done" );
}
);
return $promise;
}
По сравнению с прошлым вариантом тут добавилось создание объекта Promise в save_image и данные клиенту мы возвращаем уже из Promise, а не напрямую из контроллера. Так же при возникновении ошибки в subprocess мы не возвращаем undef, а режектим promise. В результате работы этого кода мы спавним сабпроцесс, но ответ клиенту не выдаем пока он не отработает. При этом наше приложение продолжает обслуживать других клиентов.
И опять я напомню что в таком варианте у нас нет контроля количества запущенных процессов и заДОСить свой сервер легче легкого!
Что же делать если нам надо гарантированно быстро положить сервер? :) Ведь в таком варианте мы создаем только один subprocess на запрос. Конечно же создавать несколько!
Выглядеть это будет так:
post '/upload' => sub {
my $c = shift;
# Process uploaded file
return $c->redirect_to('form') unless my $image = $c->param('image');
my $size = $image->size;
my $name = $image->filename;
mkdir $static_dir unless -e $static_dir;
my $image_path = File::Spec->catfile( $static_dir, $name );
$image->move_to($image_path);
my $promise_first = save_image( $name, $image_path, @scales[0..$#scales/2] );
my $promise_second = save_image( $name, $image_path, @scales[$#scales/2..$#scales]);
Mojo::Promise->all($promise_first, $promise_second)->then(
sub {
$c->render( text => "Thanks for uploading $size byte file $name." );
}
)->wait;
};
sub save_image {
my $image_name = shift;
my $image_path = shift;
my @scales = @_;
...
}
Здесь мы делаем два вызова метода save_image
с разным набором размеров для ресайза, каждый из которыйх возвращает свой promise.
Затем дожидаемся когда они оба отработают в Mojo::Promise->all($promise_first, $promise_second)
и после этого выдаем ответ клиенту.
В примерах выше мы не ждали никаких результатов от subprocess, в реальной жизни это относительно редкое явление. В данном случае все вообще элементарно.
Mojo::IOLoop->subprocess(
sub {
my $subprocess = shift;
...
return $work_results;
},
sub {
my ( $subprocess, $err, @results ) = @_;
$promise->reject("Subprocess error: $err @results") if $err;
$promise->resolve( @results );
}
);
И в коде обработки promise:
Mojo::Promise->all(@promises_list)->then(
sub {
for my $prom( @_) {
$log->debug("promise result: $prom->[1]")
}
}
)->wait;
На вход сабы приходит массив результатов работ промисов.
Соответственно, что вернули в $promise->resolve( @results );
, то и будет лежать. Ну помноженное на кол-во промисов.
Еще один момент - обработка ошибок. В целом тут все просто. Если промис был режектнут или в нем возникло исключение, то promise попадает в блок catch
где происходит обработка ошибки.
Mojo::Promise->all(@promises_list)->then(
sub {
for my $prom( @_) {
$log->debug("promise result: $prom->[1]")
}
}
)->catch(
sub {
my $err = shift;
$log->error("promise error: $err);
}
)->wait;
Важный момент, если мы попадаем в catch, то в then мы уже не попадем. То есть, например, запустить десяток запросов к разным серверам, а потом показать только успешные не получится. Для таких кейсов придется использовать другие механизмы.
Еще нюанс: в случае если блок catch
отсутствует и какой-то из промисов будет режектнут, то all()->then
не вызовется никогда! Так что надо не забывать его добавлять.
В общем и целом это достаточно полезная комбинация когда нам надо изредка(!) выполнять тяжелые запросы блокирующие приложение. Если такие запросы будут не редкие, то лучше использовать очереди. Например, тот же Minion, если не хочется заморачиваться со всякими RabbitMQ и прочим кровавым энтерпрайзом.
Ну и напоследок финальный код приложения:
#!/usr/bin/env perl
use strict;
use warnings;
use utf8;
use feature ':5.10';
use Mojolicious::Lite;
use Imager;
use File::Spec;
my @scales = qw/640 800 1024 2048/;
my $static_dir = app->static->paths->[0];
my %scales_paths = map { $_ => File::Spec->catfile( $static_dir, $_ ) } @scales;
my $log = Mojo::Log->new;
# Upload form in DATA section
get '/' => 'form';
# Multipart upload handler
post '/upload' => sub {
my $c = shift;
return $c->redirect_to('form') unless my $image = $c->param('image');
my $size = $image->size;
my $name = $image->filename;
mkdir $static_dir unless -e $static_dir;
my $image_path = File::Spec->catfile( $static_dir, $name );
$image->move_to($image_path);
my $promise_first = save_image( $name, $image_path, @scales[0..$#scales/2] );
my $promise_second = save_image( $name, $image_path, @scales[$#scales/2..$#scales]);
Mojo::Promise->all($promise_first, $promise_second)->then(
sub {
$c->render( text => "Thanks for uploading $size byte file $name." );
}
)->catch(
sub {
my $err = shift;
$c->render( text => "One of promises died :( $err" );
}
)->wait;
};
Mojo::IOLoop->start;
app->start;
sub save_image {
my $image_name = shift;
my $image_path = shift;
my @scales = @_;
my $imager = Imager->new();
$imager->read( file => $image_path ) or die $imager->errstr;
my $promise = Mojo::Promise->new;
Mojo::IOLoop->subprocess(
sub {
my $subprocess = shift;
for my $scale (@scales) {
mkdir $scales_paths{$scale}
unless -e $scales_paths{$scale}; #check that folder is exists
my $scaled = $imager->scale( xpixels => $scale );
$scaled->write( file =>
File::Spec->catfile( $scales_paths{$scale}, $image_name )
) or die $scaled->errstr;
}
$log->debug("done @scales");
return 1;
},
sub {
my ( $subprocess, $err, @results ) = @_;
$promise->reject("Subprocess error: $err @results") if $err;
$promise->resolve( 1, "done" );
}
);
return $promise;
}
__DATA__
@@ form.html.ep
<!DOCTYPE html>
<html>
<head><title>Upload</title></head>
<body>
%= form_for upload => (enctype => 'multipart/form-data') => begin
%= file_field 'image'
%= submit_button 'Upload'
% end
</body>
</html>
Небольшая шпаргалка по используемым мной функциям
- :q - выход :)
- :pwd - pwd
- :lcd - установить путь для текущего окна/сплита
- :e. - открыть файловый менеджер в текущей директории
:e. d - создать директорию в текущем каталоге
:sp - создать горизонтальный сплит. :10sp высота в строках для нового сплита.
- :vsp - вертикальный сплит
- ctrl+w j - перейти в сплит внизу. Аналогично для k l w
- ctrl+w = - нормализовать все сплиты.
- ctrl+w _ - расширить текущий сплит на максимальную высоту
- ctrl+w | - расширить текущий сприл на максимальную ширину
ctrl+w r - поменять местами текущий сплит с соседним
u - undo
- crtl+r - redo
. - Повторяет предыдущее изменение
\cc - комментировать/раскомментировать строку
- \sf - for {}
- \si - if {}
- \sie - if {} else {}
- \ip - print "...\n";
\is - $ =~ s///xm
\ry - perltidy
- \rr - обновить файл на диске и запустить
- \rs - обновить файл на диске и проверить синтакс
Современные технологии это всегда хорошо, но и нестареющая классика на то и классика чтобы про нее не забывать. В данном случае я говорю об IRC.
IRC прекрасная технология, вот только мобильные клиенты для него отличаются кривостью и нестабильностью работы. А уж зайти в IRC из корпоративных сетей может быть еще тем квестом.
Плюс особенностью IRC является тот факт, что никакой истории сообщений там не хранится и все что было в ваше отсутствие на канале пройдет мимо вас.
Частично решить первую проблему и полностью вторую и третью предназначен Convos.
Это веб интерфейс для IRC. Умеет поддерживать много соединений. Хранит историю и дает приятный веб-интерфейс неплохо работающий на смартфонах.
Будем ставить его из гита на свой сервер. Для таких целей я использую OpenVZ сервера от Time4vps. Серваки в Европе, стоят дешево, работают достаточно шустро и стабильно. Для наших целей достаточно самого дешевого.
Считаем что сервер у нас есть и даже с доменным именем.
Настроим SSL, нам он нужен только для того что-бы наш трафик не читал кто попало, так что хватит сертификата от LetsEncript. Тем более что получение и обновление сертификата нынче автоматизировано во всех более-менее современных дистрибутивах.
Настраиваем nginx и SSL
Устанавливаем certbot:
apt-get install certbot
Зарегистрируемся в сервисе (надо сделать один раз):
certbot register --email me@example.com
Настроим nginx для поддержки автоматического обновления сертификатов:
mkdir -p /var/www/html/.well-known/acme-challenge
echo Success > /var/www/html/.well-known/acme-challenge/example.html
Создадим инклюд для нашего локейшена для обновления сертификатов:
vim /etc/nginx/acme
location /.well-known {
root /var/www/html;
}
Заворачиваем все запросы (кроме обновления сертификатов) на https:
vim /etc/nginx/sites-aviable/default
server {
listen 80 default_server;
include acme;
location / {
return 301 https://$host$request_uri;
}
}
Перезагружаем nginx и проверяем что локейшен доступен:
service nginx reload
curl -L http://irc.example.com/.well-known/acme-challenge/example.html
Success
Удаляем файл который мы создали для теста:
rm http://irc.example.com/.well-known/acme-challenge/example.html
В принципе он ни на что не влияет, но с ним certbot будет ругаться что не удалось удалить все лишнее после обновления сертификатов.
Создаем конфиг файл для certbot, чтобы не задавал лишних вопросов и работал автоматом:
vim /etc/letsencrypt/cli.ini
authenticator = webroot
webroot-path = /var/www/html
post-hook = service nginx reload
text = True
agree-tos = True
email = imdefined@yandex.ru
Проверяем что все работает как надо:
certbot certonly --dry-run -d irc.example.com
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Starting new HTTPS connection (1): acme-staging.api.letsencrypt.org
Cert not due for renewal, but simulating renewal for dry run
Renewing an existing certificate
Performing the following challenges:
http-01 challenge for irc.example.com
Using the webroot path /var/www/html for all unmatched domains.
Waiting for verification...
Cleaning up challenges
Unable to clean up challenge directory /var/www/html/.well-known/acme-challenge
Generating key (2048 bits): /etc/letsencrypt/keys/0004_key-certbot.pem
Creating CSR: /etc/letsencrypt/csr/0004_csr-certbot.pem
Running post-hook command: service nginx reload
IMPORTANT NOTES:
- The dry run was successful.
- If you lose your account credentials, you can recover through
e-mails sent to imdefined@yandex.ru.
- Your account credentials have been saved in your Certbot
configuration directory at /etc/letsencrypt. You should make a
secure backup of this folder now. This configuration directory will
also contain certificates and private keys obtained by Certbot so
making regular backups of this folder is ideal.
Все ок, получаем полноценный сертификат:
certbot certonly -d irc.example.com
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Starting new HTTPS connection (1): acme-v01.api.letsencrypt.org
Obtaining a new certificate
Performing the following challenges:
http-01 challenge for irc.example.com
Using the webroot path /var/www/html for all unmatched domains.
Waiting for verification...
Cleaning up challenges
Generating key (2048 bits): /etc/letsencrypt/keys/0004_key-certbot.pem
Creating CSR: /etc/letsencrypt/csr/0004_csr-certbot.pem
IMPORTANT NOTES:
- Congratulations! Your certificate and chain have been saved at
/etc/letsencrypt/live/irc.example.com/fullchain.pem. Your cert will
expire on 2017-10-09. To obtain a new or tweaked version of this
certificate in the future, simply run certbot again. To
non-interactively renew *all* of your certificates, run "certbot
renew"
Эти сертификаты действительны 3 месяца. Но при установке certbot автоматом создает файл /etc/crontab.d/certbot. В принципе никаких дополнительный действий по этому поводу предпринимать не нужно, но бдительность терять не стоит ;)
Теперь создадим конфиг nginx для сервиса:
vim /etc/nginx/sites-available/irc.example.com
server {
listen 443 ssl;
ssl on;
ssl_stapling on;
server_name irc.example.com;
ssl_certificate /etc/letsencrypt/live/irc.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/irc.example.com/privkey.pem;
ssl_ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:AES:CAMELLIA:DES-CBC3-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!aECDH:!EDH-DSS-DES-CBC3-SHA:!EDH-RSA-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA';
ssl_prefer_server_ciphers on;
ssl_dhparam /etc/nginx/conf.d/dhparams.pem;
error_log /var/log/nginx/irc.example.com.error.log error;
include acme;
location / {
proxy_pass http://127.0.0.1:3001;
access_log /var/log/nginx/irc.example.com.log combined;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Enable Convos to construct correct URLs by passing on custom
# headers. X-Request-Base is only required if "location" above
# is not "/".
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Активируем этот сайт:
ln -s /etc/nginx/sites-available/irc.example.com /etc/nginx/sites-enabled/
Сгенерим усиленные параметры шифрования для DHE:
openssl dhparam -out /etc/ssl/private/dhparam.pem 2048
Добавим их в конфигурацию nginx:
vim /etc/nginx/conf.d/ssl_parameters.conf
ssl_dhparam /etc/ssl/private/dhparams.pem;
Перезапускаем nginx и наслаждаемся ошибкой 502 по тому адресу где должен быть сайт :)
service nginx restart
Настроим firewall
Создадим system.d сервис который будет поднимать firewall при старте сети:
vim /etc/systemd/system/firewall.service
[Unit]
Description=Add Firewall Rules to iptables
[Service]
Type=oneshot
ExecStart=/etc/firewall/enable.sh
[Install]
WantedBy=multi-user.target
Firewall я тут привожу самый простой, блокирует доступ ко всем портам кроме 22, 80, 443 и всех выпускает наружу:
vim /etc/firewall/enable.sh
#!/bin/sh
# iptables script generated 2017-07-04
# http://www.mista.nu/iptables
IPT="/sbin/iptables"
# Flush old rules, old custom tables
$IPT --flush
$IPT --delete-chain
# Set default policies for all three default chains
$IPT -P INPUT DROP
$IPT -P FORWARD DROP
$IPT -P OUTPUT ACCEPT
# Enable free use of loopback interfaces
$IPT -A INPUT -i lo -j ACCEPT
$IPT -A OUTPUT -o lo -j ACCEPT
# All TCP sessions should begin with SYN
$IPT -A INPUT -p tcp ! --syn -m state --state NEW -s 0.0.0.0/0 -j DROP
# Accept inbound TCP packets
$IPT -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
$IPT -A INPUT -p tcp --dport 22 -m state --state NEW -s 0.0.0.0/0 -j ACCEPT
$IPT -A INPUT -p tcp --dport 80 -m state --state NEW -s 0.0.0.0/0 -j ACCEPT
$IPT -A INPUT -p tcp --dport 443 -m state --state NEW -s 0.0.0.0/0 -j ACCEPT
Поднимаем сервис Convos
Создадим пользователя convos. От его имени будет работать сервис.
Устанавливаем perlbrew (Этот шаг можно пропустить если у вас относительно свежий дистрибутив, но я предпочитаю контролировать среду где у меня работает софт).
Perlbrew ставим от root в систему чтобы он был виден все пользователям:
apt-get install perlbrew
perlbrew init
source /opt/perlbrew/etc/bashrc
echo source /opt/perlbrew/etc/bashrc >> /etc/bash.bashrc
Устанавливаем perl 5.24.1 и переключаемся на него чтобы поставить пару модулей (на момент написания статьи Convos не ставился на 5.26 из-за ошибки в одном из сторонних модулей).
perlbrew install perl-5.24.1
perlbrew use perl-5.24.1
cpan -i Carton
После установки perl и Carton можно настраивать все дальше.
Для начала создадим system.d сервис который будет поднимать наш сервис после старта nginx (потому что если nginx не поднялся все равно ничего работать не будет).
vim /etc/systemd/system/convos.service
[Unit]
After=nginx.service
Description=ConvosService
[Service]
ExecStart=/srv/convos/start.sh
WorkingDirectory=/srv/convos
User=convos
Group=convos
Restart=always
[Install]
WantedBy=default.target
Клонируем convos из гита в /srv/convos
cd /srv/
git clone https://github.com/Nordaaker/convos.git
Переключаемся на пользователя convos и ставим все зависимости через Carton:
su convos
cd /srv/convos
export PERLBREW_ROOT=/opt/perlbrew
export PERLBREW_HOME=/srv/convos/.perlbrew_convos
source ${PERLBREW_ROOT}/etc/bashrc
carton install
После того как поставятся все зависимости создадим враппер для запуска сервиса Convos
vim /srv/convos/start.sh
#!/bin/bash
## These 3 lines are mandatory.
export PERLBREW_ROOT=/opt/perlbrew
export PERLBREW_HOME=/srv/convos/.perlbrew_convos
source ${PERLBREW_ROOT}/etc/bashrc
## Do stuff with 5.24.1
perlbrew use 5.24.1
export MOJO_REVERSE_PROXY=1
#export CONVOS_DEBUG=1
carton exec ./script/convos daemon --listen http://127.0.0.1:3001
Активируем сервис system.d и проверяем его статус:
systemctl enable convos
systemctl -l status convos
Для начала лучше запустить враппер руками от пользователя convos и убедится что нет ошибок запуска (заодно посмотреть инвайт код для регистрации через веб).
Если все в порядке - то запускаем сервис через system.d и наслаждаемся прогрессивной работой в IRC.
Если сервис таки не запустился, то посмотреть ошибки можно командой:
journalctl -u convos -f
Make the IRC great again!
Понадобилось мне тут поработать с 3d принтером из Perl. Принтер подключается к компьютеру через USB-COM переходник и прикидывается обычным COM портом со всеми вытекающими способами работы.
Для работы с ком-портом в Perl есть отличный модуль - Device::SerialPort. А для асинхронности используем классический AnyEvent. Ну и долго сказка сказывается, да быстро код пишется - пример кода:
#!/usr/bin/env perl
use v5.20;
use strict;
use AnyEvent;
use AnyEvent::Handle;
use Device::SerialPort;
my $cv = AE::cv;
# Базовые параметры подключения к порту
my $device_port = '/dev/ttyUSB0';
my $port_speed = 115200;
say "Connecting.. [$device_port] [$port_speed]";
my $port = Device::SerialPort->new($device_port);
$port->baudrate($port_speed); #Устанавливаем скорость соединения
# Чуть более продвинутые настройки
$port->handshake("none"); #Не используем handshake иначе подключение будет устанавливаться только в момент перезагрузки принтера
# Режим коммуникации 8N1
$port->databits(8);
$port->parity("none");
$port->stopbits(1);
$port->stty_echo(0); # Выключаем эхо
$port->error_msg('ON'); # Включаем выдачу ошибок от порта
# Получаем чистый хэндлер порта и с ним создаем объект AE::Handle
my $fh = $port->{'HANDLE'};
my $handle;
$handle = AnyEvent::Handle->new(
fh => $fh,
on_error => sub {
my ( $handle, $fatal, $message ) = @_;
$handle->destroy;
undef $handle;
say STDERR "$fatal : $message\n";
},
on_read => sub {
my $printer_handle = shift;
$handle->push_read(
line => sub {
my ( $printer_handle, $line ) = @_;
say sprintf( "Reply: [%s]", $line );
}
);
}
);
# Отправляем команду принтеру
$port->write("M105\n");
$cv->recv;
После запуска (если все параметры указаны верно) получим такой вывод:
Connecting.. [/dev/ttyUSB0] [115200]
Reply: [ok T:26.6 /0.0 @:0]
Подключение к принтеру и передача данных в обе стороны прошли успешно!
Важный нюанс!
Хэндлер порта полученный тут my $fh = $port->{'HANDLE'};
однонаправленный на чтение! Если попытаться туда что-то записать силами AE то получим ошибку. Писать надо напрямую в объект порта, что и происходит в предпоследней строке.
Во всех моих 3д-принтерах меня каждый раз неимоверно бесила необходимость выставлять высоту сопла над уровнем стола. Необходимость эта возникала регулярно, при каждой замене сопла или каких-то других действиях с хотэндом, требовавших его разборки.
Периодически я видел всякие конструкции на базе датчиков приближения (холла), оптических датчиков и прочих шайтан-машин. Но все они требовали точно так же выставлять уровень сопла после каких-либо манипуляций с хотом. Правда, теперь уровень задавался относительно датчика, а не стола. Но радости это все равно не приносило.
Проблему решать надо было кардинально. Для этого нужно, чтобы в качестве датчика уровня использовалось само сопло.
Первое и самое очевидное решение - кинуть один провод на хот, второй на стол, и все это дело завсести на пины концевика. Надежное и дешевое решение, если у вас стол из алюминия без покрытия. У меня на столе лежит зеркало, и такой способ мне не подходит.
Второй способ был - поставить микрик на каретку так, чтобы касание соплом стола вызывало срабатывание микрика. Способ рабочий, но у меня не получилось избавиться от люфтов в креплении хот-энда, и я от него отказался.
Затем на просторах ютуба я увидел, как работает автоматическая калибровка на датчике веса. Правда, там пленочные датчики крепились на стол, но быстрый поиск по запасникам алиэкспресса выдал металлические датчики в виде брусков, на которые уже можно повесить хот-энд.
Я заказал модуль АЦП HX711 и сам датчик на 1кг. Спустя месяц ожидания все это дело было получено и настало время прикрутить эту красоту к принтеру.
Есть два варианта подключения. Первый - это подключить АЦП напрямую к мозгам принтера и сказать прошивке, что это датчик веса. Но это решение, на мой взгляд, сильно так себе. Во-первых, поддержка таких датчиков находится пока только в экспериментальном состоянии. Во-вторых, мозгам и так есть чем заняться помимо того, чтобы постоянно читать вес от датчика и пытаться понять, что там происходит.
Значит нам нужен второй вариант: подключить это через промежуточный контроллер, который будет прикидываться концевиком для мозгов принтера. Его и выберем.
План действий будет следующий:
- Печатаем крепления датчика на эффектор
- Подключаем датчик веса к Arduino
- Подключаем Arduino с датчиком к мозгам принтера
- Редактируем прошивку принтера.
Крепления можно скачать тут. Вариант сыроватый, но рабочий и дорабатываемый по мере выявления недостатков.
Теперь подключаем контроллер к АЦП. Я нарыл в закромах Arduino Nano, но это не принципиально. На время отладки и калибровки сойдет и так, а дальше я поменяю на Attiny13, которая будет монтироваться вместе с платой АЦП прямо на эффектор для уменьшения уровня наводок по всем этим трактам. Почему на эффектор, а не рядом с основными мозгами принтера? Потому что для наилучшей точности стоит максимально укоротить провода между АЦП и датчиком веса. А если мы монтируем туда АЦП, то есть смысл прицепить туда и контроллер, чтобы от эффектора просто вести три провода к мозгам.
Также с этим АЦП есть нюанс: по умолчанию частота выборок АЦП составляет 10Гц, что слишком мало для нашего применения. То есть, технически, будет работать и так, но точность срабатывания будет плохой.
Для нормальной работы надо перевести АЦП в режим частоты опроса 80Гц. Для этого надо отцепить ногу RATE
от земли и посадить ее на VCC.
Тут есть два варианта, зависят от ревизии платы HX711.
Вариант с новой ревизией - просто отпаиваем резистор под которым написано 10Hz и запаиваем перемычку в красном квадрате (слева от которой написано 80Hz).
Если не повезло и пришла старая ревизия, то надо отпаять от платы вторую сверху ногу со стороны 4х-пинового разъема и подпаять ее к VCC или первой сверху ноге.
Все, модуль переключен в режим опроса 80Гц и наша жизнь стала немного прекраснее.
Зачем это проделывать? Так как показания датчика нестабильны из-за наличия вентилятора на голове и постоянных движений эффектора в процессе калибровки, то в скетче используется фильтр НЧ, который сглаживает скачки показаний датчика для большей надежности работы. Фильтр берет 10 значений веса и из них получает отфильтрованные показания. На частоте 80Гц выборка 10 значений занимает примерно 120мс, на частоте 10Гц - займет секунду. Соответственно, надо жертвовать фильтром, что будет приводить к ложным срабатываниям во время движения головы.
Подключаем датчик к АЦП. Соединяем провода:
- Красный -> E+
- Черный -> E-
- Белый -> A-
- Зеленый -> A+
Подключаем АЦП к Arduino:
- VCC -> 5V Arduino
- DT -> A2
- CLK -> A3
- GND -> GND Arduino
Клонируем репозиторий
git clone https://github.com/alpha6/HX711_endstop
и открываем в Arduino IDE скетч - Tenso_sensor.ino
В скетче меняем const bool DEBUG = false;
на const bool DEBUG = true;
Заливаем скетч в Arduino и через Serial monitor смотрим за показаниями.
2 раза в секунду там должна появляться строка
current weight! [848342] [848267]
цифры будут зависеть от нагрузки на датчик и погоды на Юпитере и могут плавать между измерениями, даже если датчик просто лежит на столе.
Убеждаемся, что значения датчика меняются при воздействии на него. Если меняются, значит, все собрано верно. Если нет - надо поменять местами провода DT и CLK. Я так один раз перепутал контакты DT и CLK: с виду все работало, но при попытке калибровки принтер попытался проломить соплом стол.
В установленном на принтер виде цифра от датчика должна увеличиваться при касании стола!
Теперь сделаем из Arduino концевик для мозгов принтера.
Для управления принтером у меня используется плата Melzi. Для RAMPS все будет сильно проще с точки зрения получения пинов и настройки прошивки.
Для этого нам понадобится любой оптрон и резистор на 1кОм. Оптрон я использовал 4n35, потому что он был под рукой. Любой другой подключается аналогично с разницей на нумерацию ног.
На плате Melzi всего 3 пина под концевики, на Дельте они все используются для калибровки осей. Так что нам нужен какой-то другой концевик. На своем принтере я не использую экран с кнопками, и у меня есть целый свободный разъем на 10 пин рядом с ISP, так что я буду использовать пин A1 оттуда. Для RAMPS никаких подобных телодвижений не надо, благо, концевиков у него хватает.
- Соединяем землю Arduino и пин 2 оптрона
- Сажаем пин 1 через резистор на пин D7
- Пин 4 оптрона соединяем с землей 10 пинового разъема на Melzi. Для RAMPS соединяем с землей концевика Z-Min
- Пин 5 отпрона соединяем с пином A1 10 пинового разъема на Melzi. Для RAMPS соединяем с сигнальным пином концевика Z-Min.
Схема подключения:
Устанавливаем датчик на принтер. После того, как все установлено, подводим датчик к столу. Задача - откалибровать порог срабатывания так, чтобы датчик срабатывал от касания стола, но не срабатывал от движений головы.
Для контроля срабатываний без заглядывания в сериал-монитор удобно подключить светодиод. Цепляем землю диода на пин 5 оптрона, а + через резистор на +5В. Теперь диод будет загораться при срабатывании датчика.
Теперь отредактируем прошивку.
Я не буду описывать конфигурацию прошивки с нуля, опишу только специфичные для калибровки вещи. По всему остальному в интернете полно гайдов, а эта статья и так здоровенная выходит.
Предполагается, что все остальное уже настроено для Дельты, принтер работает и нужно только автокалибровку прикрутить.
Будем использовать самые последние решения в стане прошивкостроения.
Клонируем репозиторий Marlin
git clone https://github.com/MarlinFirmware/Marlin.git
Переключаемся на бранч RcBugFix потому что в master и RC автокалибровка на дельтах не работает.
cd Marlin
git checkout -b RcBugFix
Открываем прошивку в Arduino IDE. Настраиваем все, что необходимо, и приступаем к настройке автокалибровки.
Первым делом нам надо добавить концевик Z-Min для Melzi. На RAMPS он есть, и этот пункт нужно пропустить.
N.B. Если у вас вообще не Дельта с хомингом в Z-MIN, то просто воткните Arduino в Z_MIN и смело пропускайте все настройки высот и прочее, что относится к дельтам или принтерам с хомингом стола в Z_MAX.
Открываем вкладку pins_SANGUINOLOLU_11.h
и после
#define E0_DIR_PIN 0
добавляем строки
#define Z_MIN_PROBE_PIN 30
#define Z_MIN_PIN 30
Технически, должно хватить только указания Z_MIN_PIN, но в той ревизии, что сейчас лежит в гите, есть баг, и сборка падает, если не задан Z_MIN_PROBE_PIN.
Сохраняем файл и переходим в Configuration.h
Раскомментируем строку
#define USE_ZMIN_PLUG
В строке
#define Z_MIN_PROBE_ENDSTOP_INVERTING false
false
меняем на true
Раскомментируем строку
#define FIX_MOUNTED_PROBE
Выставим смещения Z-Probe на 0
#define Y_PROBE_OFFSET_FROM_EXTRUDER 0 // Y offset: -front +behind [the nozzle]
#define Z_PROBE_OFFSET_FROM_EXTRUDER 0
Раскомментируем строку
#define Z_MIN_PROBE_ENDSTOP
Ну, и главная наша цель:
#define AUTO_BED_LEVELING_FEATURE
Тоже раскомментируем. Также раскомментируем строку
#define AUTO_BED_LEVELING_BILINEAR
Это единственный доступный тип автоуровня для Дельты. Сопло проходит по всему столу и строит квадратную карту высот, по которой потом печатает.
В строке:
#define ABL_GRID_MAX_POINTS_X 3
регулируется кол-во точек на грани квадрата. Т.е., при настройке в 3 сопло проверит высоту в 9 точках. Если указать 9 - то точек будет 81, время калибровки возрастет соответственно.
Также стоит выставить высоту области печати в значение, близкое к реальному.
#define MANUAL_Z_HOME_POS 156.8
Сопло после выполнения G28
и получения G29
идет в минимум со скоростью, указанной для хоминга. Скорость эта по умолчанию составляет 2000 мм/мин, на некотором расстоянии от поверхности стола скорость сбрасывается в 2 раза и на этой скорости происходит касание. Если заданная в прошивке высота области печати будет сильно больше реальной, то сопло просто врежется на полном ходу в стол и датчик может не успеть сработать. Точнее, датчику на срабатывание надо 120мс, за это время сопло пройдет 4мм вниз. А дальше все зависит от прочности конструкции и силы моторов. Один раз таким образом у меня получилось разбить стекло на столе.
А если область печати будет меньше реальной, то от заданной области печати сопло будет идти со скоростью в 2 раза меньше скорости калибровки, и ждать окончания процесса придется очень долго.
Скорости хоминга по Z регулирются этими строками:
#define Z_PROBE_SPEED_FAST HOMING_FEEDRATE_Z
#define Z_PROBE_SPEED_SLOW (Z_PROBE_SPEED_FAST / 2)
Еще можно включить опцию двойного касания, дает большую точность (в теории), но и занимает больше времени:
#define PROBE_DOUBLE_TOUCH
Заливаем прошивку в принтер. Проверяем, что работает G28
, командой M119 проверяем, что концевик Z-MIN
в состоянии open
.
Теперь откалибруем датчик веса на нужный уровень срабатывания.
Для этого подводим голову к поверхности стола и прижимаем сопло к столу. В этот момент должен сработать датчик. Если этого не произошло, уменьшаем порог срабатывания:
long trigger = 13000;
Имеет смысл уменьшать сразу на 1000, но это зависит от используемого датчика. У меня датчик срабатывает от легкого касания сопла пальцем. Чемь меньше будет порог срабатывания, тем лучше, но без фанатизма. Он не должен срабатывать от торможения каретки при калибровке, например.
Датчик работает. В качестве финального штриха к портрету, проверяем, что в разных положения эффектора в области печати не срабатывает датчик из-за натяжения трубки боудена или проводов вентилятора. Пишу этот пункт по собственному опыту, ибо я долго боролся со срабатыванием датчика при торможении каретки, а оказалось, что это трубка боудена дергала хот вверх со всеми вытекающими. После изменения положения мотора экструдера проблема ушла.
Теперь, когда все проверено, говорим G29. Голова поедет вниз и начнет тыкаться в стол согласно количеству точек, указанных в прошивке. После окончания калибровки будет выдана карта высот. Стоит убедиться, что все значения в ней находятся на одном уровне в переделах погрешности (второй цифры после запятой). Ну, это если стол ровный, без бугров, впадин и перекосов.
Все. На этом процесс настройки автокалибровки завершен, и можно заняться ее тюнигом.