Pull to refresh

Создаём REST-сервис на Rust. Часть 3: обновляем базу из консоли

Reading time8 min
Views17K
В предыдущей части мы разобрали конфигурационный файл базы данных, чтобы считать из него параметры соединения.

Теперь давайте реализуем непосредственно операции обновления БД: создание, обновление, удаление наших записей и соответствующий им интерфейс командной строки.

Для начала, давайте разберём аргументы программы. Её интерфейс будет выглядеть так:

const HELP: &'static str = "Usage: phonebook COMMAND [ARG]...
Commands:
    add NAME PHONE - create new record;
    del ID1 ID2... - delete record;
    edit ID        - edit record;
    show           - display all records;
    show STRING    - display records which contain a given substring in the name;
    help           - display this help.";

Здесь уже есть пара интересных моментов. const объявляет постоянную, причём такую, что она просто встраивается в место использования. Таким образом, у неё нет своего адреса в памяти — похоже на #define в C. Тип постоянной надо указывать всегда — и в данном случае он может выглядеть немного пугающе. &’static str? Что это?

Если мне не изменяет память, явно указанных времён жизни мы ещё не видели. Так вот, это — ссылка, &str, и её можно по-другому записать как &’foo str. Обычно нам не приходится явно указывать время жизни, т.к. компилятор может сам вывести его — т.е. ‘foo просто опускается.

Отмечу также, что ‘foo могло бы быть ‘bar или чем угодно ещё — это просто имя переменной. В нашем случае, можно думать так: ссылка HELP: &str имеет время жизни, называемое ‘foo, и оно равно ‘static.

Теперь о ‘static. Это время жизни, равное времени жизни программы. Наша строка непосредственно встроена в образ программы, и ей не требуется какая-либо инициализация или явное уничтожение. Поэтому она доступна всегда, пока программа исполняется. Подробнее о ‘static можно прочитать здесь.

Таким образом, мы объявили строковую постоянную, которая всегда доступна.

А вот код разбора аргументов — как всегда, сначала целиком. Затем мы рассмотрим его подробнее.

Код разбора командной строки
    let args: Vec<String> = std::env::args().collect();
    match args.get(1) {
        Some(text) => {
            match text.as_ref() {
                "add" => {
                    if args.len() != 4 {
                        panic!("Usage: phonebook add NAME PHONE");
                    }
                    let r = db::insert(db, &args[2], &args[3])
                        .unwrap();
                    println!("{} rows affected", r);
                        },
                "del" => {
                    if args.len() < 3 {
                        panic!("Usage: phonebook del ID...");
                    }
                    let ids: Vec<i32> = args[2..].iter()
                        .map(|s| s.parse().unwrap())
                        .collect();

                    db::remove(db, &ids)
                        .unwrap();
                },
                "edit" => {
                    if args.len() != 5 {
                        panic!("Usage: phonebook edit ID NAME PHONE");
                    }
                    let id = args[2].parse().unwrap();
                    db::update(db, id, &args[3], &args[4])
                        .unwrap();
                },
                "show" => {
                    if args.len() > 3 {
                        panic!("Usage: phonebook show [SUBSTRING]");
                    }
                    let s;
                    if args.len() == 3 {
                            s = args.get(2);
                    } else {
                        s = None;
                    }
                    let r = db::show(db, s.as_ref().map(|s| &s[..])).unwrap();
                    db::format(&r);
                },
                "help" => {
                    println!("{}", HELP);
                },
                command @ _  => panic!(
                    format!("Invalid command: {}", command))
            }
        }
        None => panic!("No command supplied"),
    }


Посмотрим на первую строку:

    let args: Vec<_> = std::env::args().collect();

std::env::args() просто возвращает итератор по аргументам командной строки. Почему это итератор, а не какой-нибудь статический массив? Потому что нам могут и не понадобиться все аргументы, а потенциально их может быть много. Поэтому используется итератор — он «ленив». Это в духе Rust — вы не платите за то, что вам не нужно.

Так вот, здесь у нас заведомо мало аргументов и нам будет проще иметь всё-таки нормальный вектор, из которого аргументы можно брать по индексам. Мы делаем .collect(), чтобы обойти все элементы и собрать их в определённую коллекцию.

Какую именно коллекцию? Вот тут есть тонкий момент. На самом деле, .collect() вызывает метод from_iter() той коллекции, в которую кладутся элементы. Получается, нам нужно знать её тип. Именно поэтому мы не можем опустить тип args и написать так:

    let args = std::env::args().collect();

Вот что на это скажет компилятор:

main.rs:61:9: 61:13 error: unable to infer enough type information about `_`; type annotations or generic parameter binding required [E0282]
main.rs:61     let args = std::env::args().collect();
                   ^~~~
main.rs:61:9: 61:13 help: run `rustc --explain E0282` to see a detailed explanation

Однако заметьте, что вывод типов делает своё дело: нам достаточно указать в качестве типа Vec<_>: какой тип лежит в векторе, компилятор и так знает. Нужно только уточнить, какую коллекцию мы хотим.

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

    let args: std::collections::LinkedList<_> = std::env::args().collect();

Список коллекций, реализующих from_iter, есть на странице документации типажа.

Далее мы видим:

    match args.get(1) {

.get() возвращает Ok(element), если элемент вектора существует, и None в противном случае. Мы пользуемся этим, чтобы обнаружить ситуацию, когда пользователь не указал команду:

        }
        None => panic!("No command supplied"),
    }

Если команда не совпадает ни с одной из предопределённых, мы выводим ошибку:

                command @ _  => panic!(
                    format!("Invalid command: {}", command))

Мы хотим попасть в эту ветвь при любом значении text — поэтому в качестве значения данной ветви используется _, «любое значение». Однако, мы хотим вывести эту самую неправильную команду, поэтому мы связываем выражение match с именем command с помощью конструкции command @ _. Подробнее об этом синтаксисе смотрите здесь и здесь.

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

        Some(text) => {
            match text.as_ref() {
                "add" => {
                    // handle add
                },

Если у нас есть команда, мы попадём в ветвь Some(text). Далее мы пользуемся match ещё раз, чтобы сопоставить название команды — как видите, match довольно универсален.

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

                "del" => {
                    if args.len() < 3 {
                        panic!("Usage: phonebook del ID...");
                    }
                    let ids: Vec<i32> = args[2..].iter()
                        .map(|s| s.parse().unwrap())
                        .collect();

                    db::remove(db, &ids)
                        .unwrap();
                },

Сначала нам нужны идентификаторы: мы получаем их из аргументов командной строки следующим образом:

                    let ids: Vec<i32> = args[2..].iter()
                        .map(|s| s.parse().unwrap())
                        .collect();

С let foo: Vec<_> =… .collect() мы уже знакомы. Осталось разобраться, что происходит внутри этой строчки.

args[2..] получает срез вектора — начиная с третьего элемента до конца вектора. Похоже на срезы в Python.

.iter() получает итератор по этому срезу, к которому мы применяем анонимную функцию с помощью .map():

                        .map(|s| s.parse().unwrap())

Наша анонимная функция принимает единственный аргумент — s — и разбирает его как целое число. Откуда она знает, что это должно быть целое? Отсюда:

                    let ids: Vec<i32> = 

(Хе-хе, на самом деле, даже не отсюда, а из сигнатуры функции db::remove — она принимает срез &[i32]. Вывод типов использует эту информацию, чтобы понять, что FromStr::from_str надо вызывать у i32. Поэтому мы могли быть и здесь использовать Vec<_> — но в целях документирования кода, мы указали тип явно. Про саму db::remove — ниже.)

Вообще, применение адаптеров итераторов вроде .map() — это распространённый шаблон в коде на Rust. Он позволяет получить контролируемую ленивость исполнения там, где она чаще всего нужна — при потоковом чтении каких-то данных.

Отлично, мы справились со всей подготовительной работой. Осталось обновить саму базу. insert выглядит совсем скучно. Давайте посмотрим на remove.

Кстати, а почему она записана как db::remove? Потому, что она находится в отдельном модуле. На уровне файлов, это значит, что она в отдельном исходнике: src/db.rs. Как этот модуль включается в наш главный файл? Вот так:

mod db;

Просто! Данная инструкция эквивалента вставке всего исходного кода модуля в то место, где она написана. (Но на самом деле этого не происходит, это же не сишный препроцессор. Тут компилируется весь контейнер сразу, поэтому компилятор может считать модули в память и устанавливать связи на уровне промежуточного представления, а не тупо копировать исходный код в виде текста.) Стоит отметить, что компилятор будет искать модуль в файлах src/db.rs и src/db/mod.rs — это позволяет аккуратно организовать иерархию модулей.

Теперь код нашей функции:

pub fn remove(db: Connection, ids: &[i32]) -> ::postgres::Result<u64> {
    let stmt = db.prepare("DELETE FROM phonebook WHERE id=$1").unwrap();
    for id in ids {
        try!(stmt.execute(&[id]));
    }
    Ok(0)
}

Так-так, здесь мы почти всё знаем. По порядку.

pub означает, что функция доступна снаружи модуля. В противном случае, мы бы не смогли вызвать её из main, т.к. по умолчанию все функции внутри модулей скрыты:

main.rs:81:21: 81:31 error: function `remove` is private
main.rs:81                     db::remove(db, &ids)
                               ^~~~~~~~~~

Тип возвращаемого значения выглядит странновато. ::postgres::Result?

Два двоеточия означают, что модуль postgres нужно искать от корня нашего контейнера, и не от текущего модуля. Этот модуль автоматически объявляется в main.rs, когда мы делаем extern crate postgres. Но он не становится виден в db.rs автоматически! Поэтому мы лезем в корень пространства имён с помощью ::postgres. Ещё мы могли бы повторно запросить связывание контейнера postgres в db.rs, но это не считается хорошей практикой — лучше, если все запросы на связывание находятся в одном месте, а остальные модули пользуются тем, что доступно в главном.

Хорошо, разобрались немного с модулями. Подробнее смотрите здесь.

Далее мы видим невиданный доселе макрос: try!.

Он, как подсказывает его название, пытается выполнить некую операцию. Если она завершается успехом, значением try!() будет значение, вложенное в Ok(_). Если нет, он выполняет нечто похожее на return Err(error). Это альтернатива нашим постоянным .unwrap() — теперь программа не завершится паникой в случае ошибки, а вернёт ошибку наверх для обработки вызывающей функцией.

Этим макросом можно пользоваться в функциях, которые сами возвращают Result — в противном случае макрос не сможет вернуть Err, т.к. тип возвращаемого значения и тип значения в return не совпадут.

С удалением всё. Далее я выборочно пройдусь по остальным операциям, описывая то, что мы пока не знаем.

Вот, например, как происходит работа с транзакциями:

{
    let tx: ::postgres::Transaction = db.transaction().unwrap();
    tx.execute(
            "UPDATE phonebook SET name = $1, phone = $2 WHERE id = $3",
            &[&name, &phone, &id]).unwrap();
    tx.set_commit();
}

Как видите, это типичное применение RAII. Мы просто не передаём никуда tx, и оно уничтожается по выходу из блока. Реализация его деструктора сохраняет или откатывает транзакцию в зависимости от флага успеха. Если бы мы не сделали tx.set_commit(), деструктор tx откатил бы её.

А вот как можно отформатировать строку без печати на экран:

    Some(s) => format!("WHERE name LIKE '%{}%'", s),

Когда мы создаём вектор, можно сразу указать, под сколько элементов он должен выделить память:

    let mut results = Vec::with_capacity(size);

И напоследок, ещё один пример кода в функциональном стиле:

    let max = rs.iter().fold(
        0,
        |acc, ref item|
        if item.name.len() > acc { item.name.len() } else { acc });

Этот код можно было бы записать проще, если бы мы сравнивали типы, для которых реализован типаж Ord:

    let max = rs.iter().max();

Либо, мы можем реализовать этот типаж для Record. Он требует реализации PartialOrd и Eq, а Eq, в свою очередь — PartialEq. Поэтому на самом деле придётся реализовать 4 типажа. К счастью, реализация тривиальна.

Реализация типажей
use std::cmp::Ordering;

impl Ord for Record {
    fn cmp(&self, other: &Self) -> Ordering {
            self.name.len().cmp(&other.name.len())
    }
}

impl PartialOrd for Record {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
            Some(self.name.len().cmp(&other.name.len()))
    }
}

impl Eq for Record { }

impl PartialEq for Record {
    fn eq(&self, other: &Self) -> bool {
        self.id == other.id
                && self.name == other.name
                && self.phone == other.phone
    }
}

pub fn format(rs: &[Record]) {
    let max = rs.iter().max().unwrap();
    for v in rs {
        println!("{:3}   {:.*}   {}", v.id, max.name.len(), v.name, v.phone);
    }
}


Стоит отметить, что осмысленность такой реализации под вопросом — всё же вряд ли стоит сравнивать записи БД по длине одного из полей.

Кстати, типаж Eq — это один из примеров типажей-маркеров: он не требует реализации никаких методов, а просто говорит компилятору, что какой-то тип обладает определённым свойством. Другие примеры таких типажей — это Send и Sync, про которые мы ещё поговорим.

На сегодня всё — пост и так оказался самым длинным из серии.

Теперь наше приложение реально работает, но у него пока нет REST-интерфейса. Веб-частью мы займёмся в следующий раз.
Tags:
Hubs:
+18
Comments8

Articles