Added python-rust article.

Signed-off-by: Pavel Kirilin <win10@list.ru>
This commit is contained in:
2021-12-11 18:08:26 +04:00
parent 27a3bf64f9
commit b77a2e6bf9
8 changed files with 513 additions and 22 deletions

View File

@ -45,6 +45,6 @@ deploy:
--wait --wait
--create-namespace --create-namespace
--atomic --atomic
--timeout 2m --timeout 3m
--namespace "$NAMESPACE" --namespace "$NAMESPACE"
-f "$HELM_CONFIG" -f "$HELM_CONFIG"

View File

@ -37,7 +37,7 @@ deploy
Запуск таких конфигураций выглядит следующим образом: Запуск таких конфигураций выглядит следующим образом:
```bash ```bash
docker-compose \ $ docker-compose \
-f "deploy/docker-compose.yml" \ -f "deploy/docker-compose.yml" \
-f "deploy/docker-compose.db.yml" \ -f "deploy/docker-compose.db.yml" \
-f "deploy/docker-compose.dev.yml" \ -f "deploy/docker-compose.dev.yml" \
@ -101,10 +101,10 @@ services:
Теперь запустим всё это чудо. Теперь запустим всё это чудо.
```bash ```bash
d-test docker-compose \ $ docker-compose \
-f "./deploy/docker-compose.yml" \ -f "./deploy/docker-compose.yml" \
--project-directory "." \ --project-directory "." \
run --rm script run --rm script
``` ```
Вот что будет выведено на экран. Вот что будет выведено на экран.
@ -129,11 +129,11 @@ services:
Теперь добавим ещё один файл в нашу команду запуска. Теперь добавим ещё один файл в нашу команду запуска.
```bash ```bash
d-test docker-compose \ $ docker-compose \
-f "deploy/docker-compose.yml" \ -f "deploy/docker-compose.yml" \
-f "deploy/docker-compose.dev.yml" \ -f "deploy/docker-compose.dev.yml" \
--project-directory "." \ --project-directory "." \
run --rm script run --rm script
``` ```
Вот что будет выведено на экран: Вот что будет выведено на экран:

View File

@ -133,3 +133,7 @@ Hi!
А что если я вот не хочу чтобы команда создавала и проверяла файлы? А что если я вот не хочу чтобы команда создавала и проверяла файлы?
Для этого пишут `.PHONY: ${target}`. Например у нас так объявлен таргет `run` и, даже если файл с названием `run` будет присутствовать в директории цель не будет выполняться. Для этого пишут `.PHONY: ${target}`. Например у нас так объявлен таргет `run` и, даже если файл с названием `run` будет присутствовать в директории цель не будет выполняться.
<br>
До новых встреч.

View File

@ -232,15 +232,14 @@ $ poetry publish -u "user" -p "password"
```console ```console
$ poetry add \ $ poetry add \
$ flake8 \ flake8 \
$ black \ black \
$ isort \ isort \
$ mypy \ mypy \
$ pre-commit \ pre-commit \
$ yesqa \ yesqa \
$ autoflake \ autoflake \
$ wemake-python-styleguide --dev wemake-python-styleguide --dev
---> 100%
``` ```
Теперь добавим конфигурационных файлов в корень проекта. Теперь добавим конфигурационных файлов в корень проекта.
@ -581,3 +580,7 @@ $ ab_solver 1 2
``` ```
Если запаблишить проект, то у пользователя тоже установится ваша cli-программа. Если запаблишить проект, то у пользователя тоже установится ваша cli-программа.
А на этом всё.
До новых встреч.

View File

@ -0,0 +1,479 @@
---
title: Ускоряем Python используя Rust.
description: Как встроить Rust в проект на Python.
position: 2
category: 'Python'
---
# Описание проблемы
Каким бы Python удобным не был, скоростью он похвастаться никак не может.
И для некоторых задач это достаточно критично.
Например, не так давно у меня была задача достать много информации
из файлов логов. Проблема в том, что один лог-файл занимает от 3-4 ГБ. А файлов
таких много и информацию достать требуется быстро. Изначальный вариант на Python
был написан за минут 15-20, но скорость его работы занимала 30-40 минут на один файл. После переписывания одной функции на Rust, скрипт отработал за 1 минуту.
Давайте напишем свой проект со встроенной функцией на Rust.
<br>
Задача проекта будет следующей:
Дан файл лога запросов на некоторый сервер. Требуется
найти количество запросов к определённому файлу и сумму переданных байт.
Для демонстрации мы напишем 2 функции, одна будет использовать наивное решение задачи на питоне,
вторая будет выполнять парсинг на расте.
Сам файл лог имеет следующую структуру:
```
"$line_num" "-" "$method $url" "$current_time" "$bytes_sent"
```
где,
* $line_num - номер строчки;
* $method - HTTP метод;
* $url - URL до файла, содержащий один из сгенерированных идентификаторов;
* $current_time - текущее время (время выполнения запроса);
* $bytes_sent - сколько байт было отправлено.
Данный формат логов отчасти подражает формату [G-Core labs](https://gcorelabs.com/support/articles/115000511685/).
# Проект на Python
Сначала мы напишем весь функционал на Python.
Для этого создадим проект `python-rust` по [гайду из блога](/project-start).
Для создания CLI я буду использовать [typer](https://pypi.org/project/typer/).
Весь код проекта доступен в [репозитории](https://github.com/s3rius/blog_examples/tree/master/python-rust).
```bash
$ poetry add typer
```
## Генератор логов
Для тестов напишем генератор лог-файла.
Данный генератор должен работать следующим образом.
Я указываю сколько строк лог-файла я хочу увидеть,
сколько уникальных id файлов использовать и название файла, куда писать.
Функция же генерирует лог в заданном формате.
```python{}[python_rust/main.py]
import secrets
import time
import uuid
from pathlib import Path
from typing import Any
import typer
tpr = typer.Typer()
def quote(somthing: Any) -> str:
"""
Quote string.
:param somthing: any string.
:return: quoted string.
"""
return f'"{somthing}"'
@tpr.command()
def generator( # noqa: WPS210
output: Path,
lines: int = 2_000_000, # noqa: WPS303
ids: int = 1000,
) -> None:
"""
Test log generator.
:param ids: how many file id's to generate.
:param output: output file path.
:param lines: how many lines to write, defaults to 2_000_000
"""
ids_pool = [uuid.uuid4().hex for _ in range(ids)]
with open(output, "w") as out_file:
for line_num in range(lines):
item_id = secrets.choice(ids_pool)
prefix = secrets.token_hex(60)
url = f"GET /{prefix}/{item_id}.jpg"
current_time = int(time.time())
bytes_sent = secrets.randbelow(800) # noqa: WPS432
line = [
quote(line_num),
quote("-"),
quote(url),
quote(current_time),
quote(bytes_sent),
]
out_file.write(" ".join(line))
out_file.write("\n")
typer.secho("Log successfully generated.", fg=typer.colors.GREEN)
@tpr.command()
def parser(input_file: Path, rust: bool = False) -> None:
"""
Parse given log file.
:param input_file: path of input file.
:param rust: use rust parser implementation.
"""
typer.secho("Not implemented", err=True, fg=typer.colors.RED)
def main() -> None:
"""Main program entrypoint."""
tpr()
```
Для удобства работы добавим в pyproject.toml информацию о командах.
```toml
[tool.poetry.scripts]
pyrust = "python_rust.main:main"
```
Теперь мы можем вызывать нашу программу.
```
$ pyrust --help
Usage: pyrust [OPTIONS] COMMAND [ARGS]...
Options:
--install-completion [bash|zsh|fish|powershell|pwsh]
Install completion for the specified shell.
--show-completion [bash|zsh|fish|powershell|pwsh]
Show completion for the specified shell, to
copy it or customize the installation.
--help Show this message and exit.
Commands:
generator Test log generator.
parser Parse given log file.
```
Работает отлично.
С помощью данного генератора сгенерируем файл на 8 милионов строк.
```bash
$ pyrust generator test.log --lines 8000000
Log successfully generated.
```
У нас получился файл на 1.5G. Для начального теста этого будет достаточно.
## Реализация парсера на Python
Представленный формат разделяет ифнормацию кавычками, поэтому
для сплита строк мы будем использовать встроенный в python модуль [shlex](https://docs.python.org/3/library/shlex.html).
Функция парсинга логов будет выглядить следующим образом:
```python{}[python_rust/py_parser.py]
import shlex
from pathlib import Path
from typing import Dict, Tuple
def parse_python( # noqa: WPS210
filename: Path,
) -> Dict[str, Tuple[int, int]]:
"""
Parse log file with python.
:param filename: log file.
:return: parsed data.
"""
parsed_data = {}
with open(filename, "r") as input_file:
for line in input_file:
spl = shlex.split(line)
# Splitting method and actual url.
url = spl[2].split()[1]
# Splitting url by /
# This split will turn this "/test/aaa.png"
# into "aaa".
file_id = url.split("/")[-1].split(".")[0]
file_info = parsed_data.get(file_id)
# If information about file isn't found.
if file_info is None:
downloads, bytes_sent = 0, 0
else:
downloads, bytes_sent = file_info
# Incrementing counters.
downloads += 1
bytes_sent += int(spl[4])
# Saving back.
parsed_data[file_id] = (downloads, bytes_sent)
return parsed_data
```
Давайте импортируем её, модифицируем команду парсинга и замерим скорость работы.
```python{}[python_rust/main.py]
@tpr.command()
def parser(input_file: Path, rust: bool = False) -> None:
"""
Parse given log file.
:param input_file: path of input file.
:param rust: use rust parser implementation.
"""
if rust:
typer.secho("Not implemented", input_file, color=typer.colors.RED)
return
else:
parsed_data = parse_python(input_file)
typer.secho(
f"Found {len(parsed_data)} files", # noqa: WPS237
color=typer.colors.CYAN,
)
```
Как можно видеть из кода, мы просто подсчитываем итоговое количество найденных файлов.
Посмотрим сколько времени займёт парсинг нашего сгенерированного файла.
```bash
$ time pyrust parser "test.log"
Found 1000 files
pyrust parser test.log 2443.42s user 2.10s system 99% cpu 40:59.30 total
```
Обработка данного файла заняла 40 минут. Это довольно печальный результат.
Самое время попробовать использовать Rust.
# Интеграция Rust
Для интеграции Rust в python код я буду использовать [PyO3](https://github.com/PyO3/pyo3) для биндингов и [maturin](https://github.com/PyO3/maturin) для сборки проекта.
Начнём с инициализации проекта и установки сборщика. В корневой папке проекта
создайте папку проекта с растом.
<b-message type="is-info" has-icon>
В теории можно использовать maturin как основной инструмент сборки проекта,
но это лишает вас всех прелестей poetry. Поэтому удобнее использовать Rust в
подпроекте и указать его как зависимость в списке зависимостей своего проекта.
</b-message>
```bash
$ cargo new --lib rusty_log_parser
```
Теперь создадим `pyproject.toml` и напишем туда описание нашего python пакета.
```toml{}[pyproject.toml]
[tool.poetry]
name = "rusty_log_parser"
version = "0.1.0"
description = "Log file parser with Rust core"
authors = ["Pavel Kirilin <win10@list.ru>"]
[build-system]
requires = ["maturin>=0.12,<0.13"]
build-backend = "maturin"
```
Также поправим `Cargo.toml`.
```toml{}[Cargo.toml]
[lib]
# Название модуля
name = "rusty_log_parser"
# Обязательный тип крейта, чтобы можно было использовать его
# в питоне.
crate-type = ["cdylib"]
[dependencies]
# Библиотека биндингов для питона.
pyo3 = { version = "0.15.1", features = ["extension-module"] }
# Библиотека для сплита. Повторяет функционал shlex.
shell-words = "1.0.0"
```
После этих нехитрых изменений попробуем собрать проект.
```shell
$ cd rusty_log_parser
$ cargo build
```
Теперь напишем сам парсер логов в Rust.
```rust{}[rusty_log_parser/src/lib.rs]
use std::collections::HashMap;
use std::fs::File;
use std::io;
use std::io::BufRead;
use pyo3::prelude::*;
/// Parse log file in rust.
/// This function parses log file and returns HashMap with Strings as keys and
/// Tuple of two u128 as values.
#[pyfunction]
fn parse_rust(filename: &str) -> PyResult<HashMap<String, (u128, u128)>> {
let mut result_map = HashMap::new();
// Iterating over file.
for log in io::BufReader::new(File::open(filename)?).lines().flatten() {
// Splitting log string.
if let Ok(mut spl) = shell_words::split(&log) {
// Getting file id.
let file_id_opt = spl.get_mut(2).and_then(|http_req| {
// Splitting method and URL.
http_req.split(' ').into_iter().nth(1).and_then(|url| {
// Splitting by / and getting the last split.
url.split('/')
.into_iter()
.last()
// Split file id and extension.
.and_then(|item_url| item_url.split('.').into_iter().next())
// Turning &str into String.
.map(String::from)
})
});
// Getting number of bytes sent.
let bytes_sent_opt =
spl.get_mut(4)
// Parsing string to u128
.and_then(|bytes_str| match bytes_str.parse::<u128>() {
Ok(bytes_sent) => Some(bytes_sent),
Err(_) => None,
});
if file_id_opt.is_none() || bytes_sent_opt.is_none() {
continue;
}
let file_id = file_id_opt.unwrap();
let bytes_sent = bytes_sent_opt.unwrap();
match result_map.get(&file_id) {
Some(&(downloads, total_bytes_sent)) => {
result_map.insert(file_id, (downloads + 1, total_bytes_sent + bytes_sent));
}
None => {
result_map.insert(file_id, (1, bytes_sent));
}
}
}
}
Ok(result_map)
}
/// A Python module implemented in Rust. The name of this function must match
/// the `lib.name` setting in the `Cargo.toml`, else Python will not be able to
/// import the module.
#[pymodule]
fn rusty_log_parser(_py: Python, m: &PyModule) -> PyResult<()> {
// Adding function to the module.
m.add_function(wrap_pyfunction!(parse_rust, m)?)?;
Ok(())
}
```
Это и будет наша функция, которую мы будем вызывать.
Теперь добавим `python` обёртку для нашего модуля.
Для этого в проекте `rusty_log_parser` надо создать
папку с тем же названием что и проект. В данном случае
это будет `rusty_log_parser`.
Внутри этой папки мы создадим `__init__.py`, в котором
опишем и задокументируем доступные функции модуля.
```python{}[rusty_log_parser/rusty_log_parser/__init__.py]
from pathlib import Path
from typing import Dict, Tuple
from .rusty_log_parser import parse_rust as _parse_rust
def parse_rust(input_file: Path) -> Dict[str, Tuple[int, int]]:
"""
Parse log file using Rust as a backend.
:param input_file: log file to parse.
:return: Parsed
"""
return _parse_rust(str(input_file.expanduser()))
```
Осталось только добавить свеженаписанный пакет как зависимости нашего проекта.
Для этого в корневой `pyproject.toml` надо добавить следующую зависимость.
```toml{}[pyproject.toml]
[tool.poetry.dependencies]
...
rusty_log_parser = { path = "./rusty_log_parser" }
```
Теперь мы можем использовать нашу функцию.
Перепишем нашу функцию парсера таким образом,
чтобы она использовала rust, когда был передан
соответствующий параметр.
```python{}[python_rust/main.py]
from rusty_log_parser import parse_rust
...
@tpr.command()
def parser(input_file: Path, rust: bool = False) -> None:
"""
Parse given log file.
:param input_file: path of input file.
:param rust: use rust parser implementation.
"""
if rust:
parsed_data = parse_rust(input_file)
else:
parsed_data = parse_python(input_file)
typer.secho(
f"Found {len(parsed_data)} files", # noqa: WPS237
color=typer.colors.CYAN,
)
```
Теперь проведём итоговый замер.
```bash
$ time pyrust parser test.log --rust
Found 1000 files
pyrust parser test.log --rust 20.44s user 0.35s system 99% cpu 20.867 total
```
Итого виден небольшой выйигрыш во времени в 2423 секунды, из чего следует,
что реализация на Rust быстрее всего в 122 раза.
Данной статьёй я не подталкиваю всех переписывать всё на Rust,
а просто ознакамливаю с возможностями. Но если всё же хочется,
то можно и попробовать.
До новых встреч.

View File

@ -358,6 +358,8 @@ sudo systemctl start k3s.service
# Ваше первое приложение в кластере. # Ваше первое приложение в кластере.
Вы можете следовать статье, а можете подсмотреть весь код в [репозитории](https://github.com/s3rius/blog_examples/tree/master/req_counter).
### Сервер ### Сервер
Давайте создадим своё первое приложение. Давайте создадим своё первое приложение.

View File

@ -468,4 +468,4 @@ services:
[![GIF](/images/traefik_imgs/traefik_web.png)](/images/traefik_imgs/traefik_web.png) [![GIF](/images/traefik_imgs/traefik_web.png)](/images/traefik_imgs/traefik_web.png)
Разве это не круто? До новых встреч.

View File

@ -38,11 +38,14 @@ spec:
httpGet: httpGet:
path: / path: /
port: http port: http
initialDelaySeconds: 40
periodSeconds: 20
readinessProbe: readinessProbe:
httpGet: httpGet:
path: / path: /
port: http port: http
initialDelaySeconds: 15 initialDelaySeconds: 30
periodSeconds: 15
resources: resources:
{{- toYaml .Values.resources | nindent 12 }} {{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.nodeSelector }} {{- with .Values.nodeSelector }}