RUST и инжиниринг данных - часть 2
Получение данных из API
Один из самых простых примеров, с которого можно начать, - это получение данных из конечной точки API. Это часто является началом многих интересных и увлекательных путешествий по конвейеру данных.
В нашем случае для получения описания погоды в настраиваемом местоположении путем указания широты и долготы в командной строке мы будем использовать OpenWeatherMap API .
Для получения ключа API Вам нужно создать свой аккаунт (бесплатно). Процесс создания такого ключа описан здесь.
Запуск проекта
Одно из основных различий между Rust и Python заключается в инициализации проекта.
Rust
В случае Rust все достаточно просто:
# Create the projectcargo init wxrs# Add a dependency cdwxrscargo add reqwest --features blocking
Эта команда создаст новый каталог под названием wxrs, содержащий пример «Hello World».
Она также добавит reqwest к нашим зависимостям (по аналогии с pip install). Но в отличае от pip install она также добавит Cargo.toml и создаст файл Cargo.lock, который залочит reqwest на определенную версию.
Флажок --features используется для обозначения свойств компиляции.
Мы будем использовать функцию блокировки, которая позволит нам использовать API блокировки. Это даст нам более простой интерфейс к reqwest. В конечном итоге мы будем использовать async, чтобы продемонстрировать всю мощь параллелизма Rust.
В Python нам приходится вручную поддерживать зависимости и создавать lockfiles, обновляя setup.py/ pyproject.toml, или используя такие инструменты, как pipenv или poetry.
Мы не используем никаких специфических функций, хотя Python позволяет задействовать дополнительные функции, например, pip install snowflake-connector-python[pandas].
# Cargo.toml [package] name="wxrs" version="0.1.0" edition="2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] chrono="0.4.26" polars= { version ="0.30.0", features = ["lazy"] }reqwest= { version ="0.11.18", features = ["blocking","json"] }serde= { version ="1.0.164", features = ["derive"] }serde_json="1.0.97" tempfile="3.6.0"
Python
В Python мы вручную создаем каталог, виртуальную среду, пишем файл pyproject.toml, называем наши зависимости, а затем локально устанавливаем пакет Python.
# Python venv stuffpyenv virtualenv wxpypyenv shell wxpypip install --upgrade pip build# Create the project directory and pyproject.tomlmkdir -p wxpy/wxpycdwxpyvim pyproject.toml# pyproject.toml [project] name="wxpy" version="0.0.1" dependencies= ["requests",'importlib-metadata; python_version<"3.8"',"polars","pandas"]pip install -e .
Конечно, мы можем пропустить все вышеперечисленные шаги и создать произвольный файл в любом месте, а затем запустить его с помощью python myfile.py, но цель состоит в том, чтобы создать более стабильный дистрибутив, который можно упаковать, распространить и протестировать.
Узнаем прогноз погоды
Теперь, когда у нас есть проект, давайте получим данные о погоде. Чтобы получить данные из API, мы воспользуемся пакетом requests в Python и пакетом reqwest в Rust.
Мы прочитаем ключ API из переменной окружения и получим широту и долготу из аргументов командной строки.
Учитывая, что я нахожусь в Калифорнии, имеет смысл начать с Air Pollution API.
Python
В Python мы создадим папку, чтобы упорядочить код, а затем запустим созданный ранее файл:
mkdir wxpy/wxpy/ch3# wxpy/wxpy/ch3/fetch_api.py importosimportsysimportrequestsAPI_KEY = os.getenv("OWM_APPID")def get_air_pollution(lat, lon):url =f"http://api.openweathermap.org/data/2.5/air_pollution?lat={lat}&lon={lon}&appid={API_KEY}"body = requests.get(url).textreturnbodyif__name__ =="__main__":usage =f"Usage: python {__file__} <lat> <lon>" if notAPI_KEY:print("Please set OWM_APPID environment variable")sys.exit(1)iflen(sys.argv) !=3:print(usage)sys.exit(1)lat = sys.argv[1]lon = sys.argv[2]body = get_air_pollution(lat, lon)print(body)
Rust
В Rust обычно есть файл main.rs, в котором выполняется основной код, дополнительный же код импортируется в виде модулей из других файлов. В Rust существует соглашение по компоновке пакетов.
Но поскольку мы хотим выполнять наш код напрямую, и у нас есть несколько двоичных файлов, мы создадим папку bin и сохраним в ней код для ch3.
mkdir wxrs/src/bin
// wxrs/src/bin/ch3.rs
pub fn get_air_pollution(lat: f32, lon: f32) -> String {
let api_key = std::env::var("OWM_APPID").expect(
"Environment Variable OWM_APPID not set. Please set it to your
OpenWeatherMap API key. https://home.openweathermap.org/api_keys",
);
let url = format!(
"http://api.openweathermap.org/data/2.5/air_pollution?lat={}&lon={}&appid={}",
lat, lon, api_key
);
reqwest::blocking::get(url)
.expect("request failed")
.text()
.expect("body failed")
}
pub fn main() {
let usage = format!("Usage: {} [lat] [lon]", std::env::args().next().unwrap());
let lat = std::env::args()
.nth(1)
.expect(&usage)
.parse::<f32>()
.expect(&usage);
let lon = std::env::args()
.nth(2)
.expect(&usage)
.parse::<f32>()
.expect(&usage);
let body = get_air_pollution(lat, lon);
println!("{}", body);
}
Запуск программы
Запуск программы прост на обоих языках. Мы укажем широту и долготу прекрасного города Фэрфакс, штат Калифорния, родины горного велосипеда, расположенного у подножия горы Тамалпаис.
Google дает следующие координаты: 37.9871 и -122.5889.
Python
В Python для работы с модулем мы воспользуемся -m .
# in wxpy/wxpy # export OPENWEATHERMAP_API_KEY=your-api-keypython -m wxpy.ch3.fetch_api 37.9871 -122.5889> {"coord":{"lon":-122.5889,"lat":37.9871},"list":[{"main":{"aqi":2},"components":{"co":178.58,"no":0.1,"no2":0.47,"o3":70.81,"so2":0.64,"pm2_5":2.58,"pm10":4.18,"nh3":0},"dt":1687221287}]}
Rust
В Rust мы сначала должны скомпилировать программу, и только потом ее запустить. Если мы выполним команду cargo build, Rust создаст для нас двоичный файл в папке ./target/debug/wxrs.
Мы также можем скомпилировать и запустить с помощью всего одной команды: cargo run.
При использовании cargo build Rust создаст отладочную версию нашего приложения в ./target/debug как для файла main.rs, который будет называться wxrs:
# in wxrs/cargo build./target/debug/ch3 37.9871 -122.5889> {"coord":{"lon":-122.5889,"lat":37.9871},"list":[{"main":{"aqi":2},"components":{"co":178.58,"no":0.1,"no2":0.47,"o3":70.81,"so2":0.64,"pm2_5":2.58,"pm10":4.18,"nh3":0},"dt":1687221453}]}# orcargo run --bin ch3 37.9871 -122.5889> {"coord":{"lon":-122.5889,"lat":37.9871},"list":[{"main":{"aqi":2},"components":{"co":178.58,"no":0.1,"no2":0.47,"o3":70.81,"so2":0.64,"pm2_5":2.58,"pm10":4.18,"nh3":0},"dt":1687221453}]}
Обсуждение
Если посмотреть на обе программы, то можно заметить, что подход к решению поставленной задачи почти один и тот же.
Обе программы используют внешнюю библиотеку или крейт (неслучайно названный request/reqwest).
В обеих программах мы создали функцию, которая принимает широту и долготу, получает результаты из API и возвращает их в виде текста. В ближайшее время мы рассмотрим работу со структурированными данными из JSON.
Типы
Одно из очевидных различий заключается в том, что в Rust мы объявляем типы аргументов lat и lon, а в Python - нет. Проблема с разговорами о типах заключается в том, что они неизбежно ведут к обсуждению памяти, которое может перерасти в разговор о ссылках на нулевые указатели, чего мы в основном будем избегать до следующей главы, но вот небольшой обзор.
В коде Rust мы очень четко определили типы для нашей функции:
pub fn get_air_pollution(lat:f32, lon:f32) ->String{
И lat, и lon - это 32-битные плавающие числа или числа с плавающей точкой, которые занимают ровно 32 бита памяти. Компилятор точно знает, сколько места нужно зарезервировать под эти значения: 32 бита или 4 байта.
Учитывая, что для lat и lon не требуется большая точность, чем несколько десятичных дробей, для нашего кода лучше подходит f32. Мы даже можем выбрать большую точность, используя 64-битный float или f64 в Rust, что займет 8 байт памяти.
Поскольку мы точно знаем, сколько памяти нам нужно для этих переменных, Rust может хранить эти значения в куче.
В Python мы не знаем, сколько памяти нужно для lat и lon до момента выполнения запроса, потому что Python примет в этой функции все, что угодно.
def get_air_pollution(lat, lon):
Мы можем передать строку, числа, другую функцию или даже None.
>>> def join_two(a, b):
... return f"a+b={a}+{b}"
...
>>> join_two(1,2)
'a+b=1+2'
>>> join_two(None, None)
'a+b=None+None'
>>> join_two(join_two, join_two)
'a+b=<function join_two at 0x7f8f7f7de980>+<function join_two at 0x7f8f7f7de980>'
>>> join_two(join_two, join_two(join_two, join_two))
'a+b=<function join_two at 0x7f8f7f7de980>+a+b=<function join_two at 0x7f8f7f7de980>+<function join_
two at 0x7f8f7f7de980>'
Даже строку url, потому что в Python duck-typing позволяет нам очень гибко подходить к работе с переменными. Мы можем передавать числа в f-строку для конкатенации так же легко, как и символы.
Python выделяет эти значения в куче, и оказывается, что Python выделяет около 24 байт для каждого float. Фактические значения хранятся в частной куче.
Разница между 24 и 8 байтами тривиальна для такого приложения, как это, и даже на самых ограниченных в памяти устройствах не стоит обращать на нее внимания. Но важно знать, что выделение из кучи происходит медленнее, и даже небольшие приложения могут перебирать миллионы значений.
Вы можете спросить мебя: а как же mypy? Разве это не дает нам типизацию? Mypy - это статическая проверка типов, но она не меняет базовую компиляцию кода Python. Она может дать подсказку, какими должны быть типы, но не меняет способ выделения памяти.
Работа с ошибками
Еще одно небольшое, но очень важное различие заключается в обработке ошибок. В Python ошибки обрабатываются как исключения, которые нужно поймать. Понять, когда нужно поймать исключение, - это искусство. Трудно понять, какие функции выбрасывают исключения, какие исключения следует ожидать и как и когда с ними работать.
В Rust ошибки обрабатываются как возвращаемые значения. Это гораздо более явный подход, при котором легче понять, каких ошибок ожидать и как их обрабатывать. По сути, если функция возвращает тип Result, компилятор заставит Вас обработать ошибку. Это огромное преимущество Rust, и это одна из причин, почему Rust так надежен.
Давайте подробнее рассмотрим исключение, которое мы еще не поймали. Если мы запустим программу на Python с недопустимыми аргументами, то получим ошибку ValueError.
python -m wxpy.ch3.fetch_api nice birds> ❯ python -m wxpy.ch3.fetch_api nice birds{"cod":"400","message":"wrong latitude"}./target/debug/ch3 nice birds> thread'main'panicked at'Usage: ./target/debug/ch3 [lat] [lon]: ParseFloatError { kind: Invalid }', src/main.rs:24:10note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Что у нас получилось?
В Python мы не проверили, действительны ли вводные данные или нет, и поэтому приложение отправило API неверные значения, которые вернули сообщение об ошибке. К счастью, мы получаем миллион бесплатных запросов к API в месяц, так что эта ошибка не обошлась нам дорого.
В Rust приложение «запаниковало», потому что не смогло разобрать входные данные, которые мы предоставили как float. В строках 23:24 мы вызываем разбор аргументов и ожидаем, что они также будут float.
let lat = std::env::args()
.nth(1)
.expect(&usage)
.parse::<f32>()
.expect(&usage);
Метод expect сообщает Rust, что если разбор не смог преобразовать входные данные, то приложение должно «запаниковать». Мы выводим сообщение об использовании и выходим.
Вы увидите, что метод expect и его «родственник» unwrap довольно часто используются в Rust. Они полезны для отладки, но это не лучший способ обработки ошибок. Мы рассмотрим обработку ошибок более подробно в ближайшее время.
Сравнение
Небольшое предисловие: скорость - это еще не все. Несомненно, человек, знакомый с Python, потратит гораздо больше времени на изучение Rust, чем он может сэкономить, выполняя чуть более оптимизированную программу. Но все же приятно получить представление о чем-то новом, не так ли?
Давайте воспользуемся hyperfine, чтобы сравнить эти две программы. Мы запустим каждую программу 10 раз и возьмем среднее получившееся значение. Перед сравнением Rust-приложения мы скомпилируем его с использованием параметра --release, который создает релиз, а не отладочную версию, это должно обеспечить нам более быстрое приложение.cargo build --release# in wxpyhyperfine --warmup 3 --min-runs 10 \'python -m wxpy.ch3.fetch_api 37.9871 -122.5889'\'./target/release/ch3 37.9871 -122.5889'\--export-markdown ../benchmarks/ch3_fetch_api.md
|
Команда |
Среднее значение [мс] |
Min [мс] |
Max [мс] |
Относительный показатель |
|---|---|---|---|---|
|
|
177.0 ± 6.5 |
166.2 |
189.9 |
1.00 |
|
|
320.2 ± 46.8 |
261.2 |
379.7 |
1.81 ± 0.27 |
Опять же, мы имеем дело с тривиальным приложением с простейшими требованиями, в данном случае производительность не является ключевым фактором при принятии решения о выборе языка. Но по мере создания более интенсивных приложений мы будем следить и за памятью, и за производительностью. Поверьте мне, по мере роста сложности приложения разрыв будет более очевидным.
Заключение
В этой главе мы создали простое приложение, которое получает данные из API и возвращает нужные нам результаты. Мы увидели, что Rust и Python различаются в подходе к обработке ошибок и типов, а также то, что Rust может быть быстрее и эффективнее по расходу памяти, чем Python.




