...
суббота, 17 сентября 2016 г.
Введение в futures-rs: асинхронщина на Rust [перевод]
Этот документ поможет вам изучить контейнер для языка программирования Rust — futures
, который обеспечивает реализацию futures и потоков с нулевой стоимостью. Futures доступны во многих других языках программирования, таких как C++
, Java
, и Scala
, и контейнер futures
черпает вдохновение из библиотек этих языков. Однако он отличается эргономичностью, а также придерживается философии абстракций с нулевой стоимостью, присущей Rust, а именно: для создания и композиции futures не требуется выделений памяти, а для Task
, управляющего ими, нужна только одна аллокация. Futures должны стать основой асинхронного компонуемого высокопроизводительного ввода/вывода в Rust, и ранние замеры производительности показывают, что простой HTTP сервер, построенный на futures, действительно быстр.
Эта публикация является переводом официального туториала futures-rs.
Эта документация разделена на несколько разделов:
- "Здравствуй, мир!";
- Типаж future;
- Типаж
Stream
; - Конкретные futures и поток(
Stream
); - Возвращение futures;
Task
и future;- Локальные данные задачи.
Здравствуй, мир!
Контейнер futures
требует Rust версии 1.10.0 или выше, который может быть легко установлен с помощью Rustup
. Контейнер проверен и точно работает на Windows, macOS и Linux, но PR'ы для других платформ всегда приветствуются. Вы можете добавить futures
в Cargo.toml
своего проекта следующим образом:
[dependencies]
futures = { git = "http://ift.tt/2b1uYEn" }
tokio-core = { git = "http://ift.tt/2c2Ts25" }
tokio-tls = { git = "http://ift.tt/2c2SFOD" }
Примечание: эта библиотека в активной разработке и требует получения исходников с git напрямую, но позже контейнер
будет опубликован на crates.io.
Здесь мы добавляем в зависимости три контейнера:
- futures — определение и ядро реализации
Future
иStream
; - tokio-core — привязка к контейнеру
mio
, предоставляющая конкретные
реализацииFuture
иStream
для TCP и UDP; - tokio-tls — реализация SSL/TLS на основе futures.
Контейнер futures является низкоуровневой реализацией futures, которая не несёт в себе какой-либо среды выполнения или слоя ввода/вывода. Для примеров ниже воспользуемся конкретными реализациями, доступными в tokio-core, чтобы показать, как futures и потоки могут быть использованы для выполнения сложных операций ввода/вывода с нулевыми накладными расходами.
Теперь, когда у нас есть всё необходимое, напишем первую программу. В качестве hello-world примера скачаем домашнюю
страницу Rust:
extern crate futures;
extern crate tokio_core;
extern crate tokio_tls;
use std::net::ToSocketAddrs;
use futures::Future;
use tokio_core::reactor::Core;
use tokio_core::net::TcpStream;
use tokio_tls::ClientContext;
fn main() {
let mut core = Core::new().unwrap();
let addr = "www.Rust-lang.org:443".to_socket_addrs().unwrap().next().unwrap();
let socket = TcpStream::connect(&addr, &core.handle());
let tls_handshake = socket.and_then(|socket| {
let cx = ClientContext::new().unwrap();
cx.handshake("www.Rust-lang.org", socket)
});
let request = tls_handshake.and_then(|socket| {
tokio_core::io::write_all(socket, "\
GET / HTTP/1.0\r\n\
Host: http://www.Rust-lang.org\r\n\
\r\n\
".as_bytes())
});
let response = request.and_then(|(socket, _)| {
tokio_core::io::read_to_end(socket, Vec::new())
});
let (_, data) = core.run(response).unwrap();
println!("{}", String::from_utf8_lossy(&data));
}
Если создать файл с таким содержанием по пути src/main.rs
и запустить команду cargo run
, то отобразится HTML главной страницы Rust.
Примечание: Rustc 1.10 компилирует этот пример медленно. С 1.11 компиляция происходит быстрее.
Этот код слишком большой, чтобы разобраться в нём сходу, так что пройдёмся построчно.
Взглянем на функцию main()
:
let mut core = Core::new().unwrap();
let addr = "www.Rust-lang.org:443".to_socket_addrs().unwrap().next().unwrap();
Здесь создается цикл событий, в котором будет выполняться весь ввод/вывод. После преобразуем имя хоста "www.Rust-lang.org" с использованием метода to_socket_addrs
из стандартной библиотеки.
Далее:
let socket = TcpStream::connect(&addr, &core.handle());
Получаем хэндл цикла событий и соединяемся с хостом при помощи TcpStream::connect. Примечательно, что TcpStream::connect возвращает future. В действительности, сокет не подключен, но подключение произойдёт позже.
После того, как сокет станет доступным, нам необходимо выполнить три шага для загрузки домашней страницы Rust-lang.org:
-
Выполнить TLS хэндшэйк. Работать с этой домашней страницей можно только по HTTPS, поэтому мы должны подключиться к порту 443 и следовать протоколу TLS.
-
Отправить HTTP
GET
запрос. В рамках этого руководства мы напишем запрос вручную, тем не менее, в боевых программах следует использовать HTTP клиент, построенный наfutures
. - В заключение, скачать ответ посредством чтения всех данных из сокета.
Рассмотрим каждый из этих шагов подробно.
Первый шаг:
let tls_handshake = socket.and_then(|socket| {
let cx = ClientContext::new().unwrap();
cx.handshake("www.Rust-lang.org", socket)
});
Здесь используется метод and_then типажа future, вызывая его у результата выполнения метода TcpStream::connect. Метод and_then принимает замыкание, которое получает значение предыдущего future. В этом случае socket
будет иметь тип TcpStream.
Стоит отметить, что замыкание, переданное в and_then, не будет выполнено в случае если TcpStream::connect вернёт ошибку.
Как только получен socket
, мы создаём клиентский TLS контекст с помощью ClientContext::new. Этот тип из контейнера tokio-tls
представляет клиентскую часть TLS соединения. Далее вызываем метод handshake, чтобы выполнить TLS хэндшейк. Первый аргумент — доменное имя, к которому мы подключаемся, второй — объект ввода/вывода (в данном случае объект socket
).
Как и TcpStream::connect раннее, метод handshake возвращает future. TLS хэндшэйк может занять некоторое время, потому что клиенту и серверу необходимо выполнить некоторый ввод/вывод, подтверждение сертификатов и т.д. После выполнения future вернёт TlsStream, похожий на расмотренный выше TcpStream.
Комбинатор and_then выполняет много скрытой работы, обеспечивая выполнение futures в правильном порядке и отслеживая их на лету. При этом значение, возвращаемое and_then, реализует типаж Future, поэтому мы можем составлять цепочки вычислений.
Далее отправляем HTTP запрос:
let request = tls_handshake.and_then(|socket| {
tokio_core::io::write_all(socket, "\
GET / HTTP/1.0\r\n\
Host: http://www.Rust-lang.org\r\n\
\r\n\
".as_bytes())
});
Здесь мы получили future из предыдущего шага (tls_handshake
) и использовали and_then снова, чтобы продолжить вычисление. Комбинатор write_all полностью записывает HTTP запрос, производя многократные записи по необходимости.
Future, возвращаемый методом write_all, будет выполнен, как только все данные будут записаны в сокет. Примечательно, что TlsStream скрыто шифрует все данные, которые мы записывали, перед тем как отправить в сокет.
Третья и последняя часть запроса выглядит так:
let response = request.and_then(|(socket, _)| {
tokio_core::io::read_to_end(socket, Vec::new())
});
Предыдущий future request
снова связан, на этот раз с результатом выполнения комбинатора read_to_end. Этот future будет читать все данные из сокета и помещать их в предоставленный буфер и вернёт буфер, когда обрабатываемое соединение передаст EOF.
Как и ранее, чтение из сокета на самом деле скрыто расшифровывает данные, полученные от сервера, так что мы читаем
расшифрованную версию.
Если испонение прервётся на этом месте, вы удивитесь, так как ничего не произойдёт. Это потому что всё, что мы сделали, основано на future вычислениях, и мы на самом деле не запустили их. До этого момента мы не делали никакого ввода/вывода и не выполняли HTTP запросов и т.д.
Чтобы по-настоящему запустить futures и управлять ими до завершения, необходимо запустить цикл событий:
let (_, data) = core.run(response).unwrap();
println!("{}", String::from_utf8_lossy(&data));
Здесь future response
помещается в цикл событий, запрашивая у него выполнение future. Цикл событий будет выполняться, пока не будет получен результат.
Примечательно, что вызов core.run(..)
блокирует вызывающий поток, пока future не сможет быть возвращен. Это означает, что data
имеет тип Vec<u8>
. Тогда мы можем напечатать это в stdout как обычно.
Фух! Мы рассмотрели futures, инициализирующие TCP соедениение, создающие цепочки вычислений и читающие данные из сокета. Но это только пример возможностей futures, далее рассмотрим нюансы.
Типаж Future
Типаж future является ядром контейнера futures
. Этот типаж представляет асинхронные вычисления и их результат.
Взглянем на следующий код:
trait Future {
type Item;
type Error;
fn poll(&mut self) -> Poll<Self::Item, Self::Error>;
// ...
}
Я уверен, что определение содержит ряд пунктов, вызывающих вопросы:
Item
иError
;poll
;- комбинаторы future.
Разберём их детально.
Item
и Error
type Item;
type Error;
Первая особенность типажа future, как вы, вероятно, заметили, это то, что он содержит два ассоциированных типа. Они представляют собой типы значений, которые future может получить. Каждый экземпляр Future
можно обработать как Result<Self::Item, Self::Error>
.
Эти два типа будут применяться очень часто в условиях where
при передаче futures и в сигнатурах типа, когда futures будут возвращаться.
Для примера, при возвращении future можно написать:
fn foo() -> Box<Future<Item = u32, Error = io::Error>> {
// ...
}
Или, когда принимаем future:
fn foo<F>(future: F)
where F: Future<Error = io::Error>,
F::Item: Clone,
{
// ...
}
poll
fn poll(&mut self) -> Poll<Self::Item, Self::Error>;
Работа типажа Future построена на этом методе. Метод poll — это единственная точка входа для извлечения вычисленного в future значения. Как пользователю future вам редко понадобится вызывать этот метод напрямую. Скорее всего, вы будете взаимодействовать с futures через комбинаторы, которые создают высокоуровневые абстракции вокруг futures. Однако знание того, как futures работают под капотом, будет полезным.
Подробнее рассмотрим метод poll.
Обратим внимание на аргумент &mut self
, который вызывает ряд ограничений и свойств:
- futures могут быть опрошены только одним потоком единовременно;
- во время выполнения метода
poll
, futures могут изменять своё состояние; - после заврешения
poll
владение futures может быть передано другой сущности.
На самом деле тип Poll является псевдонимом:
type Poll<T, E> = Result<Async<T>, E>;
Так же взглянем, что из себя представляет перечисление Async:
pub enum Async<T> {
Ready(T),
NotReady,
}
Посредством этого перечисления futures могут взаимодействовать, когда значение future готово к использованию. Если произошла ошибка, тогда будет сразу возвращено Err
. В противном случае, перечисление Async отображает, когда значение Future полностью получено или ещё не готово.
Типаж Future, как и Iterator
, не определяет, что происходит после вызова метода poll, если future уже обработан. Это означает, что тем, кто реализует типаж Future, не нужно поддерживать состояние, чтобы проверить, успешно ли вернул результат метод poll.
Если вызов poll возвращает NotReady
, future всё ещё требуется знать, когда необходимо выполниться снова. Для достижения этой цели future должен обеспечить следующий механизм: при получении NotReady
текущая задача должна иметь возможность получить уведомление, когда значение станет доступным.
Метод park является основной точкой входа доставки уведомлений. Эта функция возвращает Task, который реализует типажи Send
и 'static
, и имеет основной метод — unpark. Вызов метода unpark указывает, что future
может производить вычисления и возвращать значение.
Более детальную документацию можно найти здесь.
Комбинаторы future
Теперь кажется, что метод poll может внести немного боли в ваш рабочий процесс. Что если у вас есть future, который должен вернуть String
, а вы хотите конвертировать его в future, возвращающий u32
? Для получения такого рода композиций типаж future обеспечивает большое число комбинаторов.
Эти комбинаторы аналогичны комбинаторам из типажа Iterator, и все они принимают future и возвращают новый future.
Для примера, мы могли бы написать:
fn parse<F>(future: F) -> Box<Future<Item=u32, Error=F::Error>>
where F: Future<Item=String> + 'static,
{
Box::new(future.map(|string| {
string.parse::<u32>().unwrap()
}))
}
Здесь для преобразования future, возвращающий тип String
, во future, возвращающий u32
, используется map. Упаковывание в Box не всегда необходимо и более подробно будет рассмотрено в разделе возвращений futures.
Комбинаторы позволяют выражать следующие понятия:
- изменение типа future (map, map_err);
- запуск другого future, когда исходный будет выполнен (then, and_then, or_else);
- продолжение выполнения, когда хотя бы один из futures выполнился (select);
- ожидание выполнения двух future (join);
- определение поведения
poll
после вычислений (fuse).
Использование комбинаторов похоже на использование типажа Iterator
в Rust или futures
в Scala. Большинство манипуляций с futures заканчивается использованием этих комбинаторов. Все комбинаторы имеют нулевую стоимость, что означает отсутствие выделений памяти, и что реализация будет оптимизирована таким образом, как будто вы писали это вручную.
Типаж Stream
Предварительно мы рассмотрели типаж Future, который полезен в случае вычисления всего лишь одного значения в течение всего времени. Но иногда вычисления лучше представить в виде потока значений. Для примера, TCP слушатель производит множество TCP соединений в течение своего времени жизни. Посмотрим, какие сущности из стандартной библиотеки эквиваленты Future и Stream:
# items | Sync | Async | Common operations |
---|---|---|---|
1 | [Result] | [Future] | [map], [and_then] |
∞ | [Iterator] | [Stream] | [map][stream-map], [fold], [collect] |
Взглянем на типаж Stream:
trait Stream {
type Item;
type Error;
fn poll(&mut self) -> Poll<Option<Self::Item>, Self::Error>;
}
Вы могли заметить, что типаж Stream очень похож на типаж Future. Основным отличием является то, что метод poll возвращает Option<Self::Item>
, а не Self::Item
.
Stream со временем производит множество опциональных значений, сигнализируя о завершении потока возвратом Ready(None)
. По своей сути Stream представляет собой асинхронный поток, который производит значения в определённом порядке.
На самом деле, Stream — это специальный экземпляр типажа Future, и он может быть конвертирован в future при помощи метода
into_future.
Возвращённый future получает следующее значение из потока плюс сам поток, позволяющий получить больше значений позже. Это также позволяет составлять потоки и остальные произвольные futures с помощью базовых комбинаторов future.
Как и типаж Future, типаж Stream обеспечивает большое количество комбинаторов. Помимо future-подобных комбинаторов (например, then) поддерживаются потоко-специфичные комбинаторы, такие как fold.
Пример использования типажа Stream
Пример использования futures рассматривался в начале этого руководства, а сейчас посмотрим на пример использования потоков, применив реализацию метода incoming. Этот простой сервер, который принимает соединения, пишет слово "Hello!" и закрывает сокет:
extern crate futures;
extern crate tokio_core;
use futures::stream::Stream;
use tokio_core::reactor::Core;
use tokio_core::net::TcpListener;
fn main() {
let mut core = Core::new().unwrap();
let address = "127.0.0.1:8080".parse().unwrap();
let listener = TcpListener::bind(&address, &core.handle()).unwrap();
let addr = listener.local_addr().unwrap();
println!("Listening for connections on {}", addr);
let clients = listener.incoming();
let welcomes = clients.and_then(|(socket, _peer_addr)| {
tokio_core::io::write_all(socket, b"Hello!\n")
});
let server = welcomes.for_each(|(_socket, _welcome)| {
Ok(())
});
core.run(server).unwrap();
}
Как и ранее, пройдёмся по строкам:
let mut core = Core::new().unwrap();
let address = "127.0.0.1:8080".parse().unwrap();
let listener = TcpListener::bind(&address, &core.handle()).unwrap();
Здесь мы инициализировали цикл событий, вызвав метод TcpListener::bind у LoopHandle для создания TCP слушателя, который будет принимать сокеты.
Далее взглянем на следующий код:
let server = listener.and_then(|listener| {
// ...
});
Здесь видно, что TcpListener::bind
, как и TcpStream::connect
, не возвращает TcpListener
, скорее, future его вычисляет.
Затем мы используем метод and_then у Future, чтобы определить, что случится, когда TCP слушатель станет доступным.
Мы получили TCP слушатель и можем определить его состояние:
let addr = listener.local_addr().unwrap();
println!("Listening for connections on {}", addr);
Вызываем метод local_addr для печати адреса, с которым связали слушатель. С этого момента порт успешно связан, так что клиенты могут подключиться.
Далее создадим Stream.
let clients = listener.incoming();
Здесь метод incoming возвращает Stream пары TcpListener и SocketAddr. Это похоже на TcpListener из стандартной библиотеки и метод accept, только в данном случае мы, скорее, получаем все события в виде потока, а не принимаем сокеты вручную.
Поток clients
производит сокеты постоянно. Это отражает работу серверов — они принимают клиентов в цикле и направляют
их в остальную часть системы для обработки.
Теперь, имея поток клиентских соединений, мы можем манипулировать им при помощи стандартных методов типажа Stream:
let welcomes = clients.and_then(|(socket, _peer_addr)| {
tokio_core::io::write_all(socket, b"Hello!\n")
});
Здесь мы используем метод and_then типажа Stream, чтобы выполнить действие над каждым
элементом потока. В данном случае мы формируем цепочку вычислений для каждого элемента потока (TcpStream
). Мы видели метод write_all ранее, он записывает переданный буфер данных в переданный сокет.
Этот блок означает, что welcomes
теперь является потоком сокетов, в которые записана последовательность символов
"Hello!". В рамках этого руководства мы завершаем работу с соединением, так что преобразуем весь поток welcomes
в future с помощью метода for_each:
welcomes.for_each(|(_socket, _welcome)| {
Ok(())
})
Здесь мы принимаем результаты предыдущего future, write_all, и отбрасываем их, в результате чего сокет закрывается.
Следует отметить, что важным ограничением этого сервера является отсутствие параллельности. Потоки представляют собой упорядоченную обработку данных, и в данном случае порядок исходного потока — это порядок, в котором сокеты были получены, а методы and_then и for_each этот порядок сохраняют. Таким образом, сцепление(chaining) создаёт эффект, когда берётся каждый сокет из потока и обрабатываются все связанные операции на нём перед переходом к следующем сокету.
Если, вместо этого, мы хотим управлять всеми клиентами параллельно, мы можем использовать метод spawn:
let clients = listener.incoming();
let welcomes = clients.map(|(socket, _peer_addr)| {
tokio_core::io::write_all(socket, b"hello!\n")
});
let handle = core.handle();
let server = welcomes.for_each(|future| {
handle.spawn(future.then(|_| Ok(())));
Ok(())
});
Вместо метода and_then используется метод map, который преобразует поток клиентов в поток futures. Затем мы изменяем замыкание переданное в for_each используя метод spawn, что позволяет future быть запущенным параллельно в цикле событий. Обратите внимание, что spawn требует future c item/error имеющими тип ()
.
Конкретные реализации futures и потоков
На данном этапе имеется ясное понимание типажей Future
и Stream
, того, как они реализованы и как их совмещать. Но откуда все эти futures изначально пришли?
Взглянем на несколько конкретных реализаций futures и потоков.
Первым делом, любое доступное значение future находится в состоянии "готового". Для этого достаточно функций done, failed и finished. Функция done принимает Result<T,E>
и возвращает Future<Item=I, Error=E>
. Для функций failed и finished
можно указать T
или E
и оставить другой ассоцированный тип в качестве шаблона (wildcard).
Для потоков эквивалентным понятием "готового" значения потока является функция iter, которая создаёт поток, отдающий элементы полученного итератора. В ситуациях, когда значение не находится в состоянии "готового", также имеется много общих реализаций Future
и Stream
, первая из которых — функция oneshot:
extern crate futures;
use std::thread;
use futures::Future;
fn expensive_computation() -> u32 {
// ...
200
}
fn main() {
let (tx, rx) = futures::oneshot();
thread::spawn(move || {
tx.complete(expensive_computation());
});
let rx = rx.map(|x| x + 3);
}
Здесь видно, что функция oneshot возвращает кортеж из двух элементов, как, например, mpsc::channel. Первая часть tx
("transmitter") имеет тип Complete и используется для завершения oneshot
, обеспечивая значение future на другом конце. Метод Complete::complete передаст значение принимающей стороне.
Вторая часть кортежа, это rx
("receiver"), имеет тип Oneshot, для которого реализован типаж Future. Item
имеет тип T
, это тип Oneshot
. Error
имеет тип Canceled
, что происходит, когда часть Complete отбрасывается не завершая выполнения вычислений.
Эта конкретная реализация future может быть использована (как здесь показано) для передачи значений между потоками.
Каждая часть реализует типаж Send
и по отдельности является владельцем сущности. Часто использовать эту реализацию,
как правило, не рекомендуется, лучше использовать базовые future и комбинаторы, там где это возможно.
Для типажа Stream доступен аналогичный примитив channel. Этот тип также имеет две части, одна из которых используется для отправки сообщений, а другая, реализующая Stream
, для их приёма.
Канальный тип Sender имеет важное отличие от стандартной библиотеки: когда значение отправляется в канал, он потребляет отправителя, возвращая future, который, в свою очередь, возвращает исходного отправителя только когда посланное значение будет потреблено. Это создаёт противодействие, чтобы производитель не смог совершить прогресс пока потребитель от него отстаёт.
Возвращение futures
Самое необходимое действие в работе с futures — это возвращение Future. Однако как и с типажом Iterator, это пока что не так уж легко.
Рассмотрим имеющиеся варианты:
Типажи-объекты
Первое, что можно сделать, это вернуть упакованный типаж-объект:
fn foo() -> Box<Future<Item = u32, Error = io::Error>> {
// ...
}
Достоинством этого подхода является простая запись и создание. Этот подход максимально гибок с точки зрения изменений
future, так как любой тип future может быть возвращен в непрозрачном, упакованном виде.
Обратите внимание, что метод boxed возвращает BoxFuture
, который на самом деле является всего лишь псевдонимом для Box<Future + Send>
:
fn foo() -> BoxFuture<u32, u32> {
finished(1).boxed()
}
Недостатком такого подхода является выделение памяти в ходе исполнения, когда future создаётся. Box
будет выделен в
куче, а future будет помещён внутрь. Однако, стоит заметить, что это единственное выделение памяти, и в ходе выполнения future выделений более не будет. Более того, стоимость этой операции в конечном счёте не всегда высокая, так как внутри нет упакованных future (т.е цепочка комбинаторов, как правило, не требует выделения памяти), и данный минус относится только к внешнему Box
.
Пользовательские типы
Если вы не хотите возвращать Box
, можете обернуть future в свой тип и возвращать его.
Пример:
struct MyFuture {
inner: Oneshot<i32>,
}
fn foo() -> MyFuture {
let (tx, rx) = oneshot();
// ...
MyFuture { inner: tx }
}
impl Future for MyFuture {
// ...
}
В этом примере возвращается пользовательский тип MyFuture
и для него реализуется типаж Future
. Эта реализация использует future Oneshot<i32>
, но можно использовать любой другой future из контейнера.
Достоинством такого подхода является, то, что он не требует выделения памяти для Box
и по-прежнему максимально гибок.
Детали реализации MyFuture
скрыты, так что он может меняться не ломая остального.
Недостаток такого подхода в том, что он не всегда может быть эргономичным. Объявление новых типов становится слишком громоздким через некоторое время, и при частом возвращении futures это может стать проблемой.
Именованные типы
Следующая возможная альтернатива — именование возврашаемого типа напрямую:
fn add_10<F>(f: F) -> Map<F, fn(i32) -> i32>
where F: Future<Item = i32>,
{
fn do_map(i: i32) -> i32 { i + 10 }
f.map(do_map)
}
Здесь возвращаемый тип именуется так, как компилятор видит его. Функция map возвращает структуру map, которая содержит внутри future и функцию, которая вычисляет значения для map
.
Достоинством данного подхода является его эргономичность в отличие от пользовательских типов future, а также отсутствие
накладных расходов во время выполнения связанных с Box
, как это было ранее.
Недостатком данного подхода можно назвать сложность именования возвращаемых типов. Иногда типы могут быть довольно-таки большими. Здесь используется указатель на функцию (fn(i32) -> i32
), но в идеале мы должны использовать замыкание. К сожалению, на данный момент в типе возвращаемого значения не может присутствовать замыкание.
impl Trait
Благодаря новой возможности в Rust, называемой impl Trait, возможен ещё один вариант возвращения future.
Пример:
fn add_10<F>(f: F) -> impl Future<Item = i32, Error = F::Error>
where F: Future<Item = i32>,
{
f.map(|i| i + 10)
}
Здесь мы указываем, что возвращаемый тип — это "нечто, реализующее типаж Future
" с учётом указанных ассоциированных
типов. При этом использовать комбинаторы future можно как обычно.
Достоинством данного подхода является нулевая стоимость: нет необходимости упаковки в Box
, он максимально гибок, так
как реализации future скрывают возвращаемый тип и эргономичность написания настолько же хороша, как и в первом примере
с Box
.
Недостатком можно назвать, то что возможность impl Trait пока не входит в стабильную версию Rust. Хорошие новости в том, что как только она войдёт в стабильную сборку, все контейнеры, использующие futures, смогут немедленно ею воспользоваться. Они должны быть обратно-совместимыми, чтобы сменить типы возвращаемых значений с Box
на impl Trait
.
Task
и Future
До сих пор мы говорили о том, как строить вычисления посредством создания futures, но мы едва ли коснулись того, как их
запускать. Ранее, когда разговор шёл о методе poll
, было отмечено, что если poll
возвращает NotReady
, он обеспечивает отправку уведомления задаче, но откуда эта задача вообще взялась? Кроме того, где poll
был вызван впервые?
Рассмотрим Task.
Структура Task управляет вычислениями, представленными futures. Любой конкретный экземпляр future может иметь короткий цикл жизни, являясь частью большого вычисления. В примере "Здраствуй, мир!" имелось некоторое количество future, но только один выполнялся в момент времени. Для всей программы был один Task, который следовал логическому "потоку исполнения" по мере того, как обрабатывался каждый future и общее вычисление прогрессировало.
Когда future порождается она сливается с задачей и тогда эта структура может быть опрошена для завершения. Как и когда именно происходит опрос (poll), остаётся во власти функции, которая запустила future. Обычно вы не будете вызывать spawn, а скорее СpuPool::spawn с пулом потоков или Handle::spawn с циклом событий. Внутри они использут spawn и обрабатывают управляющие вызовы poll
за вас.
В продуманной реализации типажа Task
кроется эффективность контейнера futures
: когда Task
создан, все Future
в цепочке вычислений объединяются в машину состояний и переносятся из стека в кучу. Это действие является единственным, которое требует выделение памяти в контейнере futures
. В результате Task
ведёт себя таким образом, как если бы вы написали машину состояний вручную, в качестве последовательности прямолинейных вычислений.
Локальные данные задачи
В предыдущем разделе мы увидели, что каждый отдельный future является частью большого асинхронного вычисления. Это
означает, что futures приходят и уходят, но может возникнуть необходимость, чтобы у них был доступ к данным, которые живут на протяжении всего времени выполнения программы.
Futures требуют 'static
, так что у нас есть два варианта для обмена данными между futures:
-
если данные будут использованы только одним future в момент времени, то мы можем передавать владение данными между
каждым future, которому потребуется доступ к данным; - если доступ к данным должен быть параллельным, мы могли бы обернуть их в счётчик ссылок (
Arc / Rc
) или, в худшем
случае, ещё и в мьютекс (Arc<Mutex>
), если нам потребуется изменять их.
Оба эти решения относительно тяжеловесны, поэтому посмотрим, сможем ли мы сделать лучше.
В разделе Task
и Future
мы увидели, что асинхронные вычисления имеют доступ к Task
на всём протяжении его жизни, и из сигнатуры метода poll
было видно, что это изменяемый доступ. API Task
использует эти особенности и позволяет хранить данные внутри Task
. Данные ассоциированные с Task
могут быть созданы с помощью двух методов:
-
макрос
task_local!
, очень похожий на макросthread_local!
из стандартной библиотеки. Данные, которые инициализируются этим способом, будут лениво инициализироваться при первом доступе кTask
, а уничтожаться они будут, когдаTask
будет уничтожен; - структура TaskRc обеспечивает возможность создания счётчика ссылок на данные, которые доступны только в соответствующей задаче. Она может быть клонирована, так же как и
Rc
.
Примечательно, что оба эти метода объединяют данные с текущей запущенной задачей, что не всегда может быть желательно, поэтому их следует использовать осторожно.
Примечание переводчика
Спасибо Михаилу Панкову и Анне Русиной за помощь в переводе и последующее ревью! =)
Статья не имеет тип "перевод" по одной лишь причине — ожидал чек-бокса "перевод", но не увидел. После опубликовал и мне сообщили что нужно изменить тип нажав на слово "публикация" при редактировании статьи. К сожалению после отправки или публикации — изменить тип нельзя.
Вопрос к сообществу: какой вы считаете правильный перевод термина "future" в контексте статьи?
пятница, 16 сентября 2016 г.
Особенности использования машинного обучения при защите от DDoS-атак
Этот пост подготовлен по материалам выступления Константина Игнатова, Qrator Labs, на партнёрской конференции «1С-Битрикс».
Допустим, на ваш сайт началась DDoS-атака. Как вы об этом узнаете? Как ваша система безопасности определяет, что вы подверглись нападению? Каковы способы защиты? Какая последовательность действий и событий должна произойти в случае атаки?
Как правило, владелец ресурса узнает об атаке только в тот момент, когда ему начинают звонить недовольные пользователи. Эту ситуацию большинство компаний встречают неподготовленными. В момент пожара разрабатывать план спасения поздно, и все бросаются на поиски универсального средства, которое окажется под рукой. Но «волшебной пилюли» против DDoS, которая мгновенно бы устранила проблему, нет. Готовиться необходимо заранее.
Защита от DDoS — это процесс. Начинать его необходимо не тогда, когда случилась атака, а сильно заранее. Очевидно, что этот процесс относится к сфере информационной безопасности.
Что означает термин «информационная безопасность»?
В данном контексте мы говорим о противодействии двух сторон. Злоумышленники (конкуренты, вымогатели, недовольные пользователи) хотят, чтобы ваш сайт хотя бы на время перестал работать (ушёл в даунтайм). Владелец ресурса хочет стопроцентной доступности без перерыва.
Есть два основных принципа информационной безопасности:
- Стараться мыслить, как преступник, то есть мысленно пытаться встать на сторону тех людей, которые хотят отправить сайт в даунтайм. Нужно понять, как они это будут делать, какие данные им могут понадобиться, какие шаги они предпримут.
- Как можно чаще задавать себе вопрос: что может пойти не так в той или иной ситуации; что может сломаться, если мы сделаем так или иначе; к каким проблемам приведёт подключение того или иного компонента; какие минусы есть у используемых решений.
Процесс защиты от DDoS
DDoS-атака направлена на исчерпание ограниченных ресурсов. Это могут быть любые ресурсы:
- Количество SMS, которые может принять ваш телефон.
- Размер оперативной памяти.
- Канальная ёмкость.
- Всё, что угодно.
Полностью переложить проблему противодействия DDoS на поставщика соответствующей услуги не получится. Необходимо задуматься об этом еще на стадии проектирования системы. Над проблемой защиты должна работать большая команда людей:
- Сетевые инженеры должны предусмотреть, чтобы канальной ёмкости хватало хотя бы на легитимный трафик, и чтобы при этом оставался запас.
- Разработчики веб-приложений должны сделать так, чтобы один запрос не привел к исчерпанию всей оперативной памяти. Криво написанный софт может сыграть плохую службу и привести к выходу из строя сайта, даже если вы используете самую современную систему противодействия DDoS.
- Специалисты по информационной безопасности должны защищать (делать скрытым, следить, чтобы нигде не «светился») IP-адрес, на котором находится ваш сервер.
Рассмотрим, как работают системы противодействия DDoS-атакам на примере Qrator Labs — компании-партнёра «1С-Битрикс», предоставляющей сервис фильтрации трафика. Узнать её защищенный IP-адрес можно в личном кабинете в «1С-Битрикс». После чего можно перевести на этот адрес свой DNS. Старый IP-адрес продолжит работать. Одна из задач внешнего сервиса защиты состоит в том, чтобы никто не знал этот старый IP-адрес. Потому что как только он начинает «светиться», злоумышленники могут атаковать интернет-ресурс в обход защиты Qrator. Для таких случаев тоже есть решения, но их стоимость выше.
Помимо того, что защита от нападений должна быть разработана заблаговременно, сам процесс защиты целесообразно автоматизировать. Злоумышленники, как правило, редко сфокусированы на конкретном сайте. У них всё поставлено на поток, написаны универсальные скрипты. Они автоматически просканировали множество сайтов, обнаружили какую-то уязвимость на вашем и решили атаковать. Запустили скрипт, ушли спать — атака не представляет никакой сложности. А вот на разработку тактики защиты может уйти несколько часов, даже если у вас очень сильная команда специалистов. Вы вносите изменения в свой сайт, атака отражена. Злоумышленник просыпается через несколько часов, меняет пару строчек в своем скрипте, и вам наверняка придётся снова придумывать, как защититься. И так по кругу. Вряд ли ваши спецы выдержат больше 48 часов в таком режиме. После этого приходится прибегать к дорогим средствам защиты, потому что подключение под атакой всегда стоит больше. Нейтрализовать атаку, когда она уже в разгаре, вполне возможно, но это гораздо сложнее, и даунтайма уже не избежать. Внешние поставщики таких услуг, как правило, используют автоматизацию, т.к. «ручные» средства практически никогда не спасают.
Машинное обучение для автоматизации
Когда мы говорим об автоматизации в современных системах противодействия DDoS-атакам, практически всегда подразумевается использование технологий машинного обучения. Применение machine learning необходимо для нейтрализации атак уровня приложений (L7). Большинство других типов атак можно нейтрализовать методом «грубой силы». Во время атаки типа Amplification в канал поступает много одинаковых пакетов. Не нужно применять искусственный интеллект, чтобы понять, где пакет от легитимного пользователя, а где мусор. Достаточно иметь большую канальную емкость, чтобы пропустить и отфильтровать весь плохой трафик. Если емкости не хватает, может пригодиться сторонняя геораспределенная сеть, такая как Qrator, которая примет на себя излишний трафик, отфильтрует мусор и отдаст «чистые» пакеты от легитимных пользователей.
Атака на приложения происходит по другой схеме. В приложение может поступать множество самых разных запросов с большим количеством параметров. Если их достаточно много, этот поток выводит из строя базу данных. Чтобы решить проблему, необходимо уметь распознавать, где запросы от реальных пользователей, а где от ботов. Эта задача неординарная, потому что на первый взгляд они неразличимы. В её решении наиболее эффективно использовать машинное обучение.
Что такое машинное обучение?
В первую очередь, это просто набор алгоритмов, который имеет две фазы:
- Первая фаза — обучение, когда мы рассказываем нашим алгоритмам, что они должны делать.
- Вторая фаза — применение «знаний» на практике.
Алгоритмы бывают трёх типов:
- С обратной связью от человека (обучение с учителем) — мы рассказываем нашим алгоритмам, что нужно делать, а они потом учатся. Используются для задач классификации и регрессии.
- С обратной связью от данных (обучение без учителя) — мы просто показываем алгоритму данные, а он, например, находит в них аномалии, или группирует те или иные объекты по своему усмотрению. Применяются для решения задач кластеризации и поиска аномалий.
- С обратной связью от среды (теория управления). Например, когда выбираете степень масштабирования ресурсов исходя из количества ссылок на сайт в Twitter сегодня. То есть поддерживаете некий постоянный уровень нагрузки на один сервер в среднем.
В качестве примера задачи для машинного обучения я взял статистику по уровню входного трафика одного из наших клиентов (верхний график). А на нижнем графике представлен прогноз, построенный на основании тестовых данных (выделенный синим участок в начале кривой).
Сверху приведены суточные изменения объемов трафика На второй картинке смотрим график начала нагрузки, алгоритм предсказывает каким будет дальше трафик. Это пример регрессии.
Влияние злоумышленников на процесс обучения
При помощи алгоритмов машинного обучения рассчитывается оценка математического ожидания, дисперсии или других числовых характеристик распределения той или иной случайной величины.
Чтобы соответствовать принципам информационной безопасности, алгоритм должен работать в соответствии с двумя основными требованиями:
- На первом этапе алгоритм должен уметь игнорировать аномалии, которые могут оказаться в исходных данных.
- На втором этапе, когда алгоритм уже работает по нашей задаче, мы хотим понимать, почему он принял то или иное решение. Например, почему он отнес запрос к категории легитимных.
В нашем случае данные для обучения — это информация об активности пользователей. Это значит, что злоумышленники могут оказать влияние как минимум на часть данных, на которых мы собираемся учиться. А что если им придёт в голову отличная идея: так повлиять на общую статистику, чтобы наши алгоритмы научились именно тому, что хотят злоумышленники? Это вполне может произойти, а значит необходимо заранее предотвратить такую возможность.
Вот пример из нашего внутреннего отчёта о странной активности у одного из наших клиентов.
Каждый вечер, в одно и то же время количество пакетов увеличивалось. Не смертельно, сайт продолжал открываться, нагрузка была на грани допустимого (на графике логарифмический масштаб). Причиной этого могло быть всё, что угодно: cкорее всего, сисадмин что-то напутал, записал в cron операцию ежедневного сохранения бэкапов на определенное время. Но может быть кто-то пытается научить наши алгоритмы пропускать нелигитимный трафик.
Уметь так делать и уметь защищаться от этого — непростые задачи, над их решением трудятся лучшие умы, в Сети есть уже немало научных статей на эту тему. Наши алгоритмы пока никто не смог научить «плохому» и мы готову к тому, что кто-то попытается. Сейчас расскажу, почему у злоумышленников ничего не выйдет.
Как с этим бороться?
На первом этапе (обучение) важными становятся такие понятия, как робастность и breaking point.
Робастность — мера того, насколько легко можно повлиять на прогнозируемую оценку.
Breaking point — количество образцов в обучающей выборке, достаточное для искажения оценки.
Хотя оба термина очень близки друг к другу, нам ближе второй из них. Он означает количество искаженных, неправильных элементов в обучающей выборке — тех исходных данных, на которых мы учимся,— позволяющее повлиять на результаты прогнозирования.
Если breaking point равен нулю, то алгоритм может сломаться сам по себе. Если breaking point равен единице, то достаточно вбросить один-единственный неправильный запрос, и система будет предсказывать не то, что должна.
Чем выше значение breaking point, тем сложнее — а значит дороже для злоумышленника — повлиять на обучение алгоритмов. Если мы так обучили нашу систему, что breaking point высокий, вопрос противодействия перемещается в экономическую плоскость. Выигрывает тот, кто потратил меньше денег.
В идеале защита должна стоить очень мало, а ее преодоление – очень много.
На втором этапе (отработка алгоритма) нужно помнить о том, что всё может пойти не так, как планировалось. Иногда приходится анализировать постфактум, а что же всё-таки произошло. Поэтому неплохо бы иметь возможность заставить алгоритмы «рассказать», как они отработали и почему. Объяснимость действий алгоритма помогает «приглядывать» за автоматизированным процессом, а также облегчает задачи тестирования, отладки и расследования инцидентов.
Сбор данных
Алгоритмам машинного обучения нужны данные. Где их взять? В принципе, здесь у нас всё под рукой…
Какие именно данные нужно собирать?
В первую очередь, необходимо наблюдать за всеми исчерпаемыми ресурсами:
- количество соединений;
- объём трафика;
- свободная память;
- загрузка ЦПУ;
- прочие исчерпаемые ресурсы.
Собирать и хранить такие данные легко — существует множество инструментов для этого. Объём таких данных предсказуем. Если вы каждую минуту записывали один мегабайт данных телеметрии, то даже после начала DDoS-атаки вы всё равно будете записывать один мегабайт. Если вы ещё этого не делаете, то самое время начать.
Второй тип данных для анализа – поведение пользователей, которое отражено в логах (в основном это access.log и\или лог, хранящий запросы к базе данных). Они должны быть в удобном для машины формате.
В отличие от телеметрии, объем логов растёт как минимум линейно с количеством запросов. Особенно чувствительно это может быть во время DDoS-атаки, когда ресурсов катастрофически не хватает. У вас ЦПУ загружен на 100%, а тут еще система пытается сохранить 2Гб логов. Нужно сделать так, чтобы эта задача в критических ситуациях либо не запускалась, либо прерывалась. Но неплохо всё-таки, чтобы хотя бы какие-то логи сохранялись даже под атакой.
Можно хранить не все логи, а только часть. Но нужно подойти к этому с умом. Не имеет смысла записывать каждый десятый запрос, нужно сохранять сессиями. Например, вы можете вычислять некриптографический хэш от IP-адреса и сохранять только определённый диапазон этих хэшей: пусть некриптографический хэш принимает значения в диапазоне от 1 до 100. Начинаем с сохранения всех запросов. Если не успеваем, сохраняем запросы только от тех IP-адресов, хэш которых находится в диапазоне 1-90. Уменьшаем интервал, пока не сможем справиться с потоком.
Также всегда нужен хотя бы небольшой образец логов «чистого поведения» системы (то есть не под атакой). Даже если вы их не храните, периодически можно записывать небольшой дамп, чтобы иметь представление о том, как себя ведут пользователи, когда на вас нет DDoS-атаки.
Что полезного мы можем извлечь из сохраненной телеметрии и логов?
На основе телеметрии мы можем научить алгоритмы определять, когда ресурсы сервера близки к исчерпанию. Если есть логи, можем проанализировать, чем отличается поведение злоумышленников от обычных пользователей. Можем группировать пользователей по разным признакам, например, выбирать тех, кто оказывает большую нагрузку, но при этом не приносит дохода. Зачем это может понадобиться? Если сервер совсем не справляется с нормальной нагрузкой (когда нет атаки), то, к сожалению, придётся банить кого-то из легитимных пользователей. Кого? Явно не тех, кто сейчас нажимает «купить».
Примеры задач
На практике обычно алгоритмы не используются по отдельности, а складываются в цепочку задач (pipeline): то, что получается на выходе из одного алгоритма, используется как входные данные для другого. Или результаты работы одного алгоритма используются, как параметры для настройки другого.
Рассмотрим примеры задач, которые раскладываются на цепочки.
Задача №1. Оценить будущую плановую нагрузку на сайт
Задача раскладывается на следующие шаги:
- Понять, какая нагрузка бывает и как она меняется в зависимости от времени, дня недели. В результате мы должны получить несколько типов нагрузки. Например, нагрузка по рабочим дням, по выходным, во время и сразу после презентации нового IPhone.
- Оценить, к какому типу относится текущий уровень нагрузки (считаем, что в данный момент атаки нет)
- Предсказать плановое значение на основе имеющихся данных по текущей нагрузке и её типу.
Задача №2. Принять решение, нужно ли кого-нибудь банить, если мы видим признаки атаки
На текущий момент мы точно знаем, что есть атака. Возможно, следует заблокировать часть IP-адресов, которые участвуют в нападении. Но агрессивная блокировка приводит к тому, что будет забанена и часть легитимных пользователей. Эта ситуация называется False Positive, и нужно стремиться, чтобы таких случаев было как можно меньше (алгоритм ошибочно — False — относит пользователя к группе тех, кто причиняет вред — Positive). Если сервер способен «переварить» атаку (то есть обработать все запросы) без последствий для пользователей, то и делать ничего не надо. Следовательно, необходимо оценить способность сервера выдержать атаку без даунтайма. Задача раскладывается на следующие шаги:
- Определить зависимость количества ошибок\сбоев от количества запросов.
- Определить, сколько запросов необходимо отфильтровать.
Очевидно, что данная задача — это часть цепочки, которая ведёт к достижению главной цели — доступности сервера. На следующем этапе, например, необходимо решить, какие именно запросы нужно блокировать.
Задача №3. Запросы от каких реальных пользователей можно отфильтровать с меньшими потерями для бизнеса, если сервер не справляется с легитимной нагрузкой
В этой задаче мы рассматриваем ситуацию, когда легитимная нагрузка на сервер больше, чем он способен выдержать (даже без учета зловредного трафика). Такие ситуации, к сожалению, случаются. Чтобы решить эту задачу, нужно пройти два первых шага из Задачи №2. То есть данная задача — это тоже часть некоторой цепочки.
Затем идём по следующим шагам:
- Выделяем признаки сессий. Например: заходил ли посетитель на страницу оформления заказа, пришёл ли он по ссылке из рекламной кампании, авторизован ли пользователь на сайте. Составляем таблицу всех сессий и их признаков за длительный промежуток времени.
- Маркируем сессии по важности.
В идеале хорошо бы рассчитать вероятную прибыль от каждой сессии из текущих, чтобы банить только тех, кто наименее важен. Но на практике обычно применяют эвристики и упрощают задачу — блокируют тех пользователей, чьи сессии имеют признаки менее важных.
Остановимся подробнее на Задаче №1
Помните график, на котором отображена входная нагрузка на одного нашего клиента? На самом деле, ради красоты он очищен. Реальный график выглядит так:
Видим много огромных всплесков, на несколько порядков превышающих обычный уровень нагрузки. Также видно, что нагрузка непостоянна, дни бывают очень разные. То есть задача построения робастной модели на основе таких данных нетривиальна.
На эту тему написаны горы статей и проведено множество исследований. Но общепринятого идеального решения здесь нет.
Могут использоваться следующие подходы:
- Использование абсолютных отклонений.
- Робастная нормализация.
- Нелинейные обратимые преобразования (sigmoid).
- «Тяжёлые хвосты», когда нужно предположение о распределении.
- Сэмплирование ради уменьшения вероятности попадания «плохих» образцов в обучающую выборку.
Как правило можно принять, что
- в алгоритмах, основанных на деревьях breaking point < минимального размера листка;
- при кластеризации breaking point < минимального размера кластера.
Например, если применить робастную нормализацию и нелинейные обратимые преобразования, то получится так:
Сверху — исходные данные. Второй график — результат робастной нормализации с её помощью мы уменьшаем влияние аномалий. Последний график — результат нелинейного обратимого преобразования. То, что у нас есть аномалия, мы поняли уже на втором графике, после этого ее абсолютная величина нас уже не очень интересует. Мы её «обрезаем» при помощи нелинейного преобразования. Получаем данные, с которыми гораздо проще работать.
Этот же самый график можно визуализировать в виде цветной картинки.
Читается эта картинка построчно слева направо, сверху вниз. По горизонтали – секунды с начала дня, по вертикали – даты. Желтый и красный (например, в правом верхнем углу) показывают высокую нагрузку.
После применения вышеописанных подходов, мы можем сгруппировать разные дни по уровню нагрузки и кластеризовать их.
Мы можем увидеть три разных типа нагрузки, которые более явно отражены на следующих иллюстрациях.
Справа – ожидаемая нагрузка в разные типы дней. Слева — робастный аналог стандартного отклонения (разброс квантилей).
Применим это к реальной ситуации. Фиолетовая линия отражает нагрузку в течение некоторого периода времени. Она ближе всего к синей пунктирной кривой. То есть это тот тип, на который нужно ориентироваться при прогнозе нагрузки.
Поиск групп признаков
Работать с логами сложнее, т.к. информация в них хранится в форме сложных вложенных структур. Логи для машинного обучения не годятся без предварительной подготовки. В зависимости от того, какая у нас задача, мы можем выделять признаки либо у запросов, либо у сессий.
В первом случае анализируем каждый запрос в отдельности, ищем, например, признаки нелегитимных ботов… Во втором случае анализируем, по сути, поведение пользователей.
Примеры признаков запросов:
- статический или динамический запрос
- время обработки запроса
- объём памяти, который потребовался для обработки этого запроса
Примеры признаков сессий:
- пользуется ли посетитель последней версией браузера
- совпадают ли язык интерфейса его браузера с тем языком, который выбрал он в настроках на сайте;
- загружает ли посетитель статику;
- сколько раз запрошен favicon.ico;
- заходил ли посетитель на страницу оформления заказа.
Это простые признаки, мы знаем о них до начала анализа, и их можно получить с помощью элементарной функции, которая принимает запрос или сессию и возвращает массив значений конкретных признаков. Но на практике таких признаков для анализа недостаточно. Нам нужно извлечь больше информации, то есть необходимо научиться выделять новые признаки, о которых мы ещё не знаем.
Например, если в логе есть часть запросов, URL которых заканчивается на /login, то мы можем выделить это как признак и разметить каждый запрос по этому признаку (пометить единицей или нулём — заканчивается URL запроса на /login или нет). Или мы можем пометить запросы, которые пришли с сайта example.com, единицами, а все остальные — нулями. Или можем выделить признак по длительности обработки запроса: длительные, быстрые и средние.
То есть по сути мы смотрим на данные и пытаемся понять, какие признаки нам могут понадобиться. В этом суть процесса так называемого feature extraction. Потенциальных признаков бесконечно много. При этом любая группа или множество признаков также формирует новый признак. Это усложняет задачу.
Итак, задача разбивается на две подзадачи:
- требуется выделить важные признаки;
- выделить группы признаков, каждая из которых по сути тоже признак.
В реальности это может выглядеть вот так:
На картинке выше запросы превращены в признаки. На следующей картинке мы сгруппировали признаки, выделили новые и посчитали, как часто они встречаются.
В этой задаче мы анализировали около 60 000 запросов (что не так уж и много, но это просто пример).
В правом столбике указано сколько запросов соответствует данной группе признаков. Видно, что количество разное, и что один запрос может попасть в несколько групп. То есть есть запросы, которые не попали в некоторые группы. Мы помечаем каждый запрос массивом из десяти нулей или единиц. Единица означает, что запрос соответствует группе, ноль — что не попадает в неё. Таким образом, у нас получается массив в виде матрицы 60 000х10. С этой числовой информацией можно работать также, как описано выше.
Чтобы выделить такие признаки, используются специальные алгоритмы. Описывать их здесь подробно не буду.
Общая идея заключается в том, чтобы преобразовать лог в специальную базу данных. Эта база данных должна уметь отвечать на ряд запросов. Например: найти в логе для всех элементарных признаков все их возможные сочетания, удовлетворяющие некоторому критерию, и отсортировать по частоте встречаемости.
Другой тип базы данных работает не с множествами, а с последовательностями. Это нужно для анализа сессий, потому что сессия, по сути, — последовательность запросов. Такая БД умеет выделять все подпоследовательности определённого типа. Например, запрос к ней: найти в логе среди всех подпоследовательностей во всех сессиях такие, которые удовлетворяют определённому критерию, и отсортировать эти подпоследовательности по количеству сессий, в которых они встретились.
Третий тип баз данных позволяет работать с переходами из одной части сайта в другую в рамках одной сессии. То есть каждая сессия теперь — граф. БД должна уметь находить все подграфы, удовлетворяющие определенному критерию, и сортировать их по количеству сессий, в которых они встретились.
Этот процесс называется pattern discovery. В качестве паттернов выступают множества, последовательности или графы.
С помощью такого анализа можно найти интересующие нас группы пользователей. Результат такого анализа можно изучать даже вручную.
Нужно ко всему этому готовиться заранее
Ещё раз подчеркну, что на всё это понадобится время. Защиту от DDoS нельзя просто взять и включить (в момент атаки) — её нужно готовить заранее.
Нужно найти подходящих людей и обучить их. Нужно собрать данные, которые можно будет использовать для обучения. Возможно, потребуется что-то изучить, посмотреть вручную, найти узкие места. Алгоритмы системы защиты нужно обучить. Необходимо проверить, что сервер или, напрмер, почтовый демон не «светит» незащищённый IP.
Всё это — часть большого процесса и не может произойти моментально.
С другой стороны, хочу заметить, что, результаты работы системы защиты от DDOS можно использовать для бизнес-аналитики. Например, отдать в работу маркетинговому отделу данные группы пользователей, которых мы решили не банить, потому что они приносят доход.
Комментарии (0)
четверг, 15 сентября 2016 г.
Работаем с Azure IoT устройствами из приложений UWP
В продолжение статьи Отправляем данные с Arduino в Azure IoT Hub я сейчас расскажу о том, как можно считывать и отправлять данные в IoT Hub облака Azure из UWP приложения. Делается это с использованием клиентской библиотеки Microsoft.Azure.Devices.Client. Для мониторинга этих, отправленных в облако сообщений, можно использовать Device Explorer или iothub-explorer.
Кроме того, расскажу о том, как создать простое приложение UWP, отправляющее данные из облака на устройство. Напоследок, приведу пример того, как можно получить сообщение из Azure IoT hub на Arduino MKR1000.
Имитируем Azure IoT устройство с помощью UWP приложения
Скачиваем Connected Service for Azure IoT Hub (текущая версия 1.5). Устанавливаем. Создаем проект универсального приложения. Добавляем ссылку на подключенную службу
Нажимаем «Настроить». На выбор нам будет предложено 2 варианта.
Первый вариант классический. В случае если у нас обычный проект без особых требований к безопасности. Строка подключения к IoT хабу будет хранится в коде.
Второй вариант экспериментальный. Устройство регистрируется на Windows Device Portal. Затем после выбора в меню пункта «TPM configuration» необходимо установить TPM (Trusted Platform Module) на устройство и ввести данные ключа из Azure хаба. В результате устройство не будет хранить первичный ключ доступа к Azure. Вместо этого TPM устройства будет генерировать SAS токены с коротким сроком жизни.
Выбрав первый вариант и введя идентификационные данные пользователя Azure, получим окно выбора хаба:
В моем случае выбирать особо не приходится, так как я создал только один хаб. Его и добавляю.
Получаю приглашение выбрать устройство. Опять же, в моем случае, только одно устройство зарегистрировано (пользуюсь бесплатными возможностями Azure)
После выбора устройства происходит установка различных необходимых пакетов:
По завершению установки нам откроется страница с мануалом, который предлагает использовать:
SendDeviceToCloudMessageAsync()
для отправки сообщений. И для получения сообщений:
ReceiveCloudToDeviceMessageAsync()
Добавим кнопочку и проверим, получим ли мы сообщение, отправленное с помощью DeviceExplorer-а:
private async void btnCheck_Click(object sender, RoutedEventArgs e)
{
string message = await AzureIoTHub.ReceiveCloudToDeviceMessageAsync();
}
Если с получением все должно быть понятно, то при отправке сообщений с помощью SendDeviceToCloudMessageAsync отправляется всегда одна и та же строка текста. Рассмотрим код, который находится в файле AzureIoTHub.cs:
public static async Task SendDeviceToCloudMessageAsync()
{
var deviceClient = DeviceClient.CreateFromConnectionString(deviceConnectionString, TransportType.Amqp);
#if WINDOWS_UWP
var str = "Hello, Cloud from a UWP C# app!";
#else
var str = "Hello, Cloud from a C# app!";
#endif
var message = new Message(Encoding.ASCII.GetBytes(str));
await deviceClient.SendEventAsync(message);
}
Не совсем понимаю, почему Task не принимает строку текста в качестве параметра, чтобы отправить именно ее, а отправляет hard-coded значение «Hello…». Может быть, дело в том, что используется кодировка ASCII (хотя консольное приложение, работая с Azure IoT, использует кодировку UTF8). Перестраховка? Скорее всего, это просто шаблон, код которого несложно подправить:
public static async Task SendDeviceToCloudMessageAsync(string texttosend)
{
var deviceClient = DeviceClient.CreateFromConnectionString(deviceConnectionString, TransportType.Amqp);
var message = new Message(Encoding.UTF8.GetBytes(texttosend));
await deviceClient.SendEventAsync(message);
}
Теперь вы можете с помощью Device Explorer получить сообщение или отправить его в UWP приложение.
» Ссылка на англоязычную статью: Connect your Windows app to Azure IoT Hub with Visual Studio
» Ссылка на GitHub страницу проекта Connected Service for Azure IoT Hub (если что-то вдруг не так, есть куда сабмиттить баг)
Еще раз уточню, что может возникнуть путаница. Отправлять сообщение можно как с устройства в облако, так и с облака на устройство. Приведу пример приложения UWP, которое на этот раз отправляет сообщение с облака на устройство.
Отправляем сообщение из облака на устройство с помощью приложения UWP
В менеджере пакетов NuGet необходимо найти по фразе Microsoft.Azure.Devices пакет и установить его. На всякий случай прямая ссылка: Microsoft Azure IoT Service SDK
Добавить пространства имен:
using Microsoft.Azure.Devices;
using System.Threading.Tasks;
using System.Text;
И следующие переменные:
static ServiceClient serviceClient;
static string connectionString = "{строка подключения iot hub}";
Где строка подключения берется с портала Azure:
Нам понадобится метод, отправляющий текст на устройство
private async static Task SendCloudToDeviceMessageAsync()
{
var commandMessage = new Message(Encoding.UTF8.GetBytes("light on"));
// даем команду включить светодиод
await serviceClient.SendAsync("pseudoDevice", commandMessage);
}
Здесь pseudoDevice это id устройства на которое будет отправлено сообщение.
Остается в MainPage добавить после this.InitializeComponent():
serviceClient = ServiceClient.CreateFromConnectionString(connectionString);
И где-нибудь в событии нажатия на кнопку можно вызвать таск, отправляющий сообщение на устройство:
private async void Button_Click(object sender, RoutedEventArgs e)
{
await SendCloudToDeviceMessageAsync();
}
Наше UWP приложение готово. Теперь можно отправлять команду «light on» нашему устройству.
Получение сообщения с Azure IoT хаба платой Arduino MKR1000
С помощью следующего скетча можно получить сообщение из хаба. В случае, если получено сообщение с текстом «light on», Arduino MKR1000 включит светодиод.
Скетч необходимо немного сконфигурировать. Ввести данные вашей Wi-Fi сети:
char ssid[] = "xxx"; // SSID имя вашей Wi-Fi точки доступа
char pass[] = "xxxxxxxx"; // пароль вашей сети
И данные вашего Azure IoT хаба:
char hostname[] = "xxxxxx.azure-devices.net"; // host name address for your Azure IoT Hub
char feeduri[] = "/devices/xxxxxxx/messages/devicebound?api-version=2016-02-03"; // здесь нужно вместо xxxxxxx ввести id устройства
char authSAS[] = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
Как получить строку authSAS (SAS Token) с помощью Device Explorer я описовал в прошлой статье. Это строка, которая начинается с «SharedAccessSignature sr=».
Комментарии (0)
среда, 14 сентября 2016 г.
Сложный квест для хабравчан: 25 уровней
Всем привет, меня зовут Влад, я программист в Mail.Ru Group. В 2010 году я делал квест для хабраюзеров и его прошло более 10 тысяч человек. На этот день программиста я решил сделать что-то похожее, но немного не успел: усложнял квест и не смог остановиться. (-:
Решать головоломку здесь: puzzle.mail.ru
Призы! Первому, кто ответит на все 25 вопросов, мы подарим Raspberry Pi 3 от DIY-сообщества Mail.Ru Group. Еще есть промежуточный приз: тот, кто первым пройдет 15-й уровень, получит от меня инвайт на Хабр.
Как проходить:
– Логин и пароль при регистрации нужны, чтобы продолжить игру на другом компьютере;
– Головоломка состоит из 25 уровней. На каждом уровне необходимо выяснить (найти или вычислить) секретное слово и ввести его в поле ввода сверху;
– Часто на уровне есть подсказки, но иногда наоборот – лишняя отвлекающая информация. Подсказки часто скрыты. Не забывайте заглядывать в HTML-код страницы;
– Для прохождения игры необходимо начальное знание web-технологий, информатики, знать что такое ASCII, RGB и система счисления. Программистом быть в принципе не обязательно. (-:
– На мобильном или планшете вряд ли получится пройти, т.к. вам, скорее всего, понадобится какой-нибудь текстовый и графический редактор.
Задавайте вопросы в комментариях. Запоздало поздравляю всех причастных с днем программиста!
Комментарии (0)
вторник, 13 сентября 2016 г.
Android Dev: продолжение подкастов о профессиональной разработке под Android
Но прежде, чем анонсировать темы предстоящих выпусков, мы пройдемся по прошлым четырнадцати, ведь многие из них до сих пор не теряют свою актуальность и рекомендованы к прослушиваю каждому, кто не безразличен к разработке приложений под Android.
Выпуски подкаста
Выпуск первый
Выпуск первый. SDK 23. Как я поднял версию и ничего не сломал
В самом первом выпуске Мы долго и упорно обсуждали миграцию приложений на SDK 23, чтобы поддержать работу на Android Marsmallow. Истории успешного обновления приложений с миллионами пользователей. Кто с чем столкнулся, какие проблемы и решения. Doze, Standby, Runtime Permissions, более мелкие изменения. Обсудили либы, помогающие при работе с пермишенами.
Выпуск второй
У меня AsyncTasks и нет MVP
Идеальная архитекутра — вечный грааль, который все ищут, но никто не знает точного ответа, где же она. Мы плотно поговорили о современных архитектурных решениях. MVP, как оно в жизни, в больших и малых проектах. Как нам поможет DI, Rx. Какие БД сейчас в моде.
Выпуск третий
Gitflow, CI, QA, которые пишут UI Тесты, и другие аббревиатуры
Собравшись тимлидами и CTO разных команд мобильной разработки, мы прошлись по процессам. Мы поделились опытом адаптации gitflow к реалиям динамичной разработки мобильных приложений. Построили самый удобный Continuous Integration сервер. Прошлись по Continuous Delivery и даже Continuous Translations. И запустили на нем все тесты, которые только можно сделать, чтобы постоянно проверять наш код.
Выпуск четвёртый
Тесты. Вся правда из первых уст
Очень занимательная беседа о тестировании, очень полезна как новичкам, так и тем, кто думает, что у него уже все хорошо с тестами в его проектах. Мы поговорили обо всех аспектах тестирования в Android. И, самое главное, мы рассказали, что TDD не просто возможен, а он обязателен в современной мобильной разработке!
Выпуск пятый
RxJava
У нас состоялся серьезный разговор о реактивном программировании. Ответы на самые главные вопросы: Зачем? С чего начать? И как же прикрутить к жизненному циклу? Участники выпуска — матерые реактивщики, за плечами которых не только полное внедрение подхода в мобильные проекты, но и свои реактивные библиотеки.
Выпуск шестой
Обзор Android N и 33 совета разработчикам
Во время записи проекта, мне казалось, что DI тема исчерпана, но мы всеже по просьбам слушателей готовим большой и полноценный выпуск про Dagger и другие фреймворки для DI в Android.
А в данном выпуске мы сменили акценты и решили обсудить насущное.
К тому же вышел превью Android N, а это повод для большого разговора. Во второй половине выпуска мы покритиковали и подкорректировали статью о 33-х советах от разработчика Android другим разработчикам.
Выпуск седьмой
Kotlin. Готов ли он к продакшен разработке
Этот выпуск оказался самым популярным в первом сезоне, и это не удивительно, ведь так много людей интересуются разработкой под этот легкий в использовании и удобный язык. Мы пригласили Яна, разработчика Kotlin из Jetbrains, чтобы он рассказал нам все из первых уст.
Выпуск восьмой
NDK. Когда нужно использовать нативный код и как бороться с подводными камнями
Мы обсудили важнейшие аспекты работы с NDK: как мы работаем с нативным кодом, какие плюшки, какие проблемы, стоит ли выносить часть логики в нативный код.
А те кто дослушал выпуск до конца, те уже знают много интересного: почему тормозит Android, как устроены Spotify и Telegram, будет ли Swift в Android.
Выпуск девятый
Данный выпуск был записан буквально на коленке. Я был в отеле в США, Антон только вернулся в Россию, и мы, с трудом собравшись вместе в разных часовых поясах, обсуждили конференцию Google I/O 2016 и все ее новинки. Начиная с этого выпуска, мы перешли на летний часовой формат выпусков без обсуждения больших и глобальных тем.
Выпуск десятый
Droidcon Berlin, Android N DP4, новые библиотеки и насущные вопросы
В выпуске мы прошлись по темам берлинского Droidcon’а, обсудили Fingerprint и некоторые другие вопросы.
Выпуск одиннадцатый
Об Instagram, Facebook, Firebase и новых библиотеках от Джейка Вортона
Чтобы больше узнать о выпуске, пройдите по ссылке на подкаст, со списком статей и библиотек, которые мы обсудили.
Выпуск двенадцатый
Чтобы больше узнать о выпуске, пройдите по ссылке на подкаст, со списком статей и библиотек, которые мы обсудили.
Выпуск тринадцатый
О Doze, Gradle 3, Dagger 2.6, Protobuf’ах и многом другом
Чтобы больше узнать о выпуске, пройдите по ссылке на подкаст, со списком статей и библиотек, которые мы обсудили.
Выпуск четырнадцатый
Мы обсуждаем Android 7.0 Nougat, Jrebel, Kotlin, Gradle и многое другое
Чтобы больше узнать о выпуске, пройдите по ссылке на подкаст, со списком статей и библиотек, которые мы обсудили.
Дальнейшее равзитие подкаста
Уже сегодня доступен наш новый выпуск про анимации и material design, а в ближайших планах у нас выпуски с глубоким погружением в Gradle, Dagger, безопасность мобильных приложений, интернет вещей, phisycal web, Android Wear, виртуальную реальность, project Tango. Так же мы ждем выхода виртуального Daydream и Instant Apps, чтобы обсудить их с вами. Еще в ноябре будет слет экспертов гугл, после которого появятся непременно новые поводы и анонсы. Плюс ко всему в начале каждого выпуска мы делимся наиболее интересными ссылками и находками и обсуждаем новости.
«Я очень рад каждому из 14 наших выпусков и хочу поблагодарить каждого из участников подкаста и особенно их семьи, которые снисходительно относились к нашим полуночным записям. Но в первую очередь я благодарен нашим слушателям, для которых мы собственно и собираемся каждые две недели обсудить новые темы подкаста». — Ведущий подкаста nekdenis
В предверии DroidCon Moscow 2016, который состоится 22-го сентября, мы подготовили для вас небольшой конкурс. От вас требуется написать в комментариях, что больше всего вас радует в мобильной разработке, и чтобы вы хотели изменить в разработке под Android. Авторов самых, по нашему (ведущих подкаста) мнению комментариев ждет бесплатный инвайт на DroidCon, а если комментарий окажется наиболее технически глубоким и обоснованным, мы с радостью пригласим на запись одного из наших выпусков.
Ждем всех на DroidCon и в обсуждениях новых выпусков!
Комментарии (0)