Всем привет.

Какое-то время назад я видел в небезызвестном блоге пост о том, как реализовать на Go простую программу, работающую с БД, а затем сделать на её базе целый REST-сервис. Я решил проверить, насколько сложно сделать аналогичную программу на Rust и поделиться результатами.



Мы начнём с работы с БД и создадим обычное консольное приложение, а затем добавим, так сказать, REST-фронтенд.

Несколько вступительных замечаний.

Для нетерпеливых — вот законченный проект на GitHub. Он включает в себя и реализацию REST-сервиса. Всех остальных же приглашаю читать дальше.

В целом, я постараюсь подробно проиллюстрировать процесс разработки со всеми ошибками, касающимися Rust, их причинами и способами устранения. Я думаю, знание типичных проблем и способов их решения сильно помогает новичкам в языке. Не бойтесь компилятора, он — ваш друг.

Вам понадобится установленный Rust (как установить). Должна работать любая версия после 1.0 — как stable, так и nightly. Я пробовал несколько в интервале 1.1-1.3.

Сам код здесь прототипного качества — я сейчас не пытаюсь сделать очень надёжную или читабельную программу. Разобравшись в нём, можно будет задуматься о правильности и стиле позже. Однако, и написана эта версия была весьма быстро.

Теперь к делу.

Как и любой проект на Rust, не требующий особых хитростей со сборкой, наша программа будет использовать Cargo. Создадим новый проект:

$ cargo new --bin rust-phonebook
$ cd rust-phonebook

Cargo заботливо создаёт в директории репозиторий Git.

Как это выглядит
$ git status

On branch master

Initial commit

Untracked files:
  (use "git add <file>..." to include in what will be committed)

	.gitignore
	Cargo.toml
	src/

nothing added to commit but untracked files present (use "git add" to track)


И мы можем сразу собрать и запустить нашу программу-заглушку:

$ cargo run

   Compiling rust-phonebook v0.1.0 (file:///home/mkpankov/rust-phonebook)
     Running `target/debug/rust-phonebook`
Hello, world!

После чего закоммитим наши изменения в репозиторий и перейдём к сути нашей программы.



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

Сначала я приведу весь код целиком, а затем объясню каждую его часть. Ниже — содержимое src/main.rs.

Код
extern crate postgres;

use postgres::{Connection, SslMode};

struct Person {
    id: i32,
    name: String,
    data: Option<Vec<u8>>
}

fn main() {
    let conn =
        Connection::connect(
            "postgres://postgres:postgres@localhost",
            &SslMode::None)
        .unwrap();

    conn.execute(
        "CREATE TABLE person (
           id              SERIAL PRIMARY KEY,
           name            VARCHAR NOT NULL,
           data            BYTEA
         )",
        &[])
        .unwrap();

    let me = Person {
        id: 0,
        name: "Михаил".to_string(),
        data: None
    };

    conn.execute(
        "INSERT INTO person (name, data) VALUES ($1, $2)",
        &[&me.name, &me.data])
        .unwrap();

    let stmt = conn.prepare("SELECT id, name, data FROM person").unwrap();

    for row in stmt.query(&[]).unwrap() {
        let person = Person {
            id: row.get(0),
            name: row.get(1),
            data: row.get(2)
        };
        println!("Нашли человека: {}", person.name);
    }
}


Давайте разберём все по порядку.

fn main() {
    let conn =
        Connection::connect(
            "postgres://postgres:postgres@localhost",
            &SslMode::None)
        .unwrap();

Первая строка в нашем новом main — подключение к БД. Тут стоит сразу рассказать подробнее.

Мы предполагаем, что локально запущен сервер PostgreSQL на порту по умолчанию, а имя пользователя и пароль — «postgres». Для этого нам, конечно, нужно установить PostgreSQL. Можно посмотреть, например, это руководство. Укажите ваше имя пользователя, имеющего доступ к базе, и его пароль вместо «postgres:postgres».

Помимо этого, не забудьте инициализировать базу данных.

Сам вышеупомянутый Connection — тип из контейнера postgres (документация). Поэтому мы запрашиваем его связывание вверху файла

extern crate postgres;

и вводим в область видимости Connection и SslMode

use postgres::{Connection, SslMode};

Если попробовать собрать программу прямо сейчас, вы получите другую ошибку:

$ cargo build
   Compiling rust-phonebook v0.1.0 (file:///home/mkpankov/rust-phonebook.finished)
src/main.rs:1:1: 1:23 error: can't find crate for `postgres`
src/main.rs:1 extern crate postgres;
              ^~~~~~~~~~~~~~~~~~~~~~
error: aborting due to previous error
Could not compile `rust-phonebook`.

To learn more, run the command again with --verbose.

Это означает, что компилятор не нашёл подходящий контейнер. Это потому, что мы не указали его в зависимостях нашего проекта. Давайте сделаем это в Cargo.toml (подробнее):

[dependencies]

postgres = "0.9"

Теперь всё должно собираться. Но если вы не запустили сервер, то получите такую ошибку:

 
$ cargo run
     Running `target/debug/rust-phonebook`
thread '<main>' panicked at 'called `Result::unwrap()` on an `Err` value: IoError(Error { repr: Os { code: 111, message: "Connection refused" } })', ../src/libcore/result.rs:732

Это непосредственный результат нашего .unwrap() — он вызывает панику текущего потока, если Result был не Ok(_) — т.е. произошла ошибка соединения.

Кстати, backtrace для неё можно увидеть, если запустить программу с выставленным RUST_BACKTRACE=1 в окружении (работает только в отладочной версии программы!).

Backtrace
$ RUST_BACKTRACE=1 cargo run

     Running `target/debug/rust-phonebook`
thread '<main>' panicked at 'called `Result::unwrap()` on an `Err` value: IoError(Error { repr: Os { code: 111, message: "Connection refused" } })', ../src/libcore/result.rs:732
stack backtrace:
   1:     0x56007b30a95e - sys::backtrace::write::haf6e4e635ac76143Ivs
   2:     0x56007b30df06 - panicking::on_panic::ha085a58a08f78856lzx
   3:     0x56007b3049ae - rt::unwind::begin_unwind_inner::hc90ee27246f12475C0w
   4:     0x56007b304ee6 - rt::unwind::begin_unwind_fmt::ha4be06289e0df3dbIZw
   5:     0x56007b30d8d6 - rust_begin_unwind
   6:     0x56007b3390c4 - panicking::panic_fmt::he7875691f9cbe589SgC
   7:     0x56007b25e58d - result::Result<T, E>::unwrap::h10659124002062427088
                        at ../src/libcore/macros.rs:28
   8:     0x56007b25dcfd - main::h2f2e9aa4b99bad67saa
                        at src/main.rs:13
   9:     0x56007b30d82d - __rust_try
  10:     0x56007b30fbca - rt::lang_start::hefba4015e797c325hux
  11:     0x56007b27d1ab - main
  12:     0x7fb3f21076ff - __libc_start_main
  13:     0x56007b25db48 - _start
  14:                0x0 - <unknown>


Фух, всего одна строчка, а столько способов накосячить! Надеюсь, вы не сильно напуганы и готовы продолжать.

Положительным моментом здесь является то, что мы явно говорим, что хотим уронить программу при ошибке соединения. Когда мы захотим сделать из нашей игрушки нормальный продукт, простой текстовый поиск по .unwrap() покажет, с чего стоит начать. Дальше я не буду останавливаться на этом моменте.

Создаём таблицу:

    conn.execute(
        "CREATE TABLE person (
           id              SERIAL PRIMARY KEY,
           name            VARCHAR NOT NULL,
           data            BYTEA
         )",
        &[])
        .unwrap();

Странное &[] в конце — это пустой срез. У данного запроса нет параметров, поэтому мы не передаём их.

Почему срез, а не массив? Хороший стиль в Rust — не принимать владение, если объекты нужны только для чтения. Иначе нам пришлось бы клонировать значение для передачи в функцию, т.е. она «поглотила» бы его. Подробнее о владении читайте тут.

Далее мы создаём структуру, представляющую собой нашу запись, которую мы будем добавлять в таблицу:

    let me = Person {
        id: 0,
        name: "Михаил".to_string(),
        data: None
    };

Принципиально, сейчас смысла складывать эти данные в структуру нет, но дальше это нам поможет. Кстати, вот её объявление:

struct Person {
    id: i32,
    name: String,
    data: Option<Vec<u8>>
}

Теперь выполним собственно вставку:

    conn.execute(
        "INSERT INTO person (name, data) VALUES ($1, $2)",
        &[&me.name, &me.data])
        .unwrap();

Здесь уже у нашего запроса есть параметры. Они подставляются с помощью строковой интерполяции в нумерованные поля $1, $2 и т.д. И теперь наш срез параметров не пуст — он содержит ссылки на соответствующие поля структуры.

Далее мы подготавливаем запрос к базе, чтобы прочитать то, что записали:

    let stmt = conn.prepare("SELECT id, name, data FROM person").unwrap();

Думаю, ничего интересного. Это просто создание объекта-запроса. Повторяющиеся запросы имеет смысл не пересоздавать, а хранить, для увеличения производительности. Мы также могли бы сразу выполнить запрос без создания «приготовленного объекта».

В конце мы выполняем сам запрос. Пройдёмся по каждой строчке:

    for row in stmt.query(&[]).unwrap() {

Здесь мы обходим массив результатов запроса. Как всегда, запрос мог бы завершиться ошибкой. Список параметров снова пуст — &[].

Теперь снова собираем структуру из результатов запроса.

        let person = Person {
            id: row.get(0),
            name: row.get(1),
            data: row.get(2)
        };

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

Наконец, печатаем сообщение с результатом:

        println!("Нашли человека: {}", person.name);
    }
}

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

В следующей части мы добавим конфигурацию сервера в INI-файле. Оставайтесь с нами!

Комментарии (26)


  1. youROCK
    23.09.2015 16:16

    Выглядит вполне ничего, за исключением постоянного unwrap(). Впрочем, в go есть такое же в виде if err != nil. Ещё обидно, что стек-трейсы можно увидеть только в отладочной версии программы (в go они будут видны всегда).


    1. mkpankov
      23.09.2015 16:26
      +2

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

      Представьте себе, что вам нужно в проекте на go из 10000 строк найти все места, где ошибки не обрабатываются, и оценить, сколько временеи нужно будет на приделывание правильной обработки. Вам придётся делать это, вручную вычитывая код. В Rust достаточно текстового поиска.


      1. Antontzi
        23.09.2015 18:20
        +3

        В го можно искать по коду "_".


        1. mkpankov
          24.09.2015 09:28

          А оно используется в go только в этом контексте?


          1. alexeyknyshev
            24.09.2015 17:26
            +1

            Обычно да


      1. mirrr
        24.09.2015 07:20

        Go линтеры, вроде errcheck отлично справляются с этой задачей


        1. mkpankov
          24.09.2015 09:28

          А как они работают?


          1. mirrr
            24.09.2015 10:05

            Анализирует код проекта и выдает список проблем с указанием файлов, номеров строк. В моем случае подсвечивает строки в редакторе (sublime), в которых есть необработанные ошибки.


            1. mkpankov
              24.09.2015 10:19

              Я имею ввиду, как анализируют. Знают, какие функции ошибки возвращают, или действительно, достаточно искать _ как выше предлагают?


              1. mirrr
                24.09.2015 11:24

                Ну не я разработчик линтера.

                Вообще выявляет как при использовании методов из пакетов, подключенных с гитхаба, так и собственных методов в проекте и стандартных. А по _ можно искать только маскированные ошибки, ведь по сути можно значение даже не получать.

                image


              1. miolini
                24.09.2015 22:59

                Они парсят весь код на Go согласно синтаксису и проверяют.


  1. vedenin1980
    23.09.2015 17:07

    Я не совсем понял: Rust сумеет при этом коде защитить от SQL injection или выполнит все что придет из запросов?


    1. mkpankov
      23.09.2015 17:09

      Вы про законченную версию из репозитория? Потому что здесь-то запросы к базе статические и защищать не от чего.


      1. vedenin1980
        23.09.2015 17:25

        Да, вы пробовали отправлять sql injection в запросах законченной версии? Они нормально отсекались?


        1. mkpankov
          23.09.2015 18:03
          +3

          Это делает не Rust как таковой, а библиотека postgres, но да, он типобезопасен:

          Заголовок спойлера
          $ RUST_BACKTRACE=1 cargo run -- del '-1 OR 1=1'
               Running `target/debug/rust-phonebook del -1\ OR\ 1=1`
          thread '<main>' panicked at 'called `Result::unwrap()` on an `Err` value: ParseIntError { kind: InvalidDigit }', ../src/libcore/result.rs:736
          stack backtrace:
             1:     0x555c75a62f49 - sys::backtrace::tracing::imp::write::ha2610c4d9f39e7d20qt
             2:     0x555c75a62128 - panicking::on_panic::h0137eb721b989e66zqx
             3:     0x555c75a551fe - sys_common::unwind::begin_unwind_inner::h9323ab6063ed22d03es
             4:     0x555c75a55998 - sys_common::unwind::begin_unwind_fmt::hd6d1e3432b74d1dc9ds
             5:     0x555c75a619a1 - rust_begin_unwind
             6:     0x555c75a91aff - panicking::panic_fmt::h3b414ef7ca9e7ec0wZK
             7:     0x555c7586bff6 - result::Result<T, E>::unwrap::h17631234665356226696
                                  at ../src/libcore/macros.rs:28
             8:     0x555c7586ae97 - main::closure.11714
                                  at src/main.rs:85
             9:     0x555c7586ae3c - ops::impls::_&'a mut F.FnOnce<A>::call_once::h16646540071590734845
                                  at ../src/libcore/ops.rs:1795
            10:     0x555c7586ade2 - option::Option<T>::map::h18348351940653665552
                                  at ../src/libcore/option.rs:425
            11:     0x555c7586ad9c - iter::Map<I, F>.Iterator::next::h6843473521919548427
                                  at ../src/libcore/iter.rs:1686
            12:     0x555c7586aadc - vec::Vec<T>.FromIterator<T>::from_iter::h16476383075473108179
                                  at ../src/libcollections/vec.rs:1160
            13:     0x555c7586aa79 - iter::Iterator::collect::h14488226810590600205
                                  at ../src/libcore/iter.rs:567
            14:     0x555c758627f7 - main::h89fbfb3db45c0143MAa
                                  at src/main.rs:84
            15:     0x555c75a64b94 - sys_common::unwind::try::try_fn::h5089277237340746579
            16:     0x555c75a61808 - __rust_try
            17:     0x555c75a6482d - rt::lang_start::h377d03c3ab92e2f1kox
            18:     0x555c758b9609 - main
            19:     0x7ffb915336ff - __libc_start_main
            20:     0x555c7581eaf8 - _start
            21:                0x0 - <unknown>
          


  1. grossws
    23.09.2015 18:19

    Спасибо, посмотрел на код на github'е: iron выглядит очень неплохо, тем более для низкоуровнего языка.
    С нетерпением жду следующих статей.


    1. mkpankov
      23.09.2015 18:25

      Только я iron напрямую не использую. То, что вы видите — это hyper.


      1. mkpankov
        23.09.2015 18:32

        Прошу прощения, сам запутался. Использую, конечно, просто router — это компонент hyper, так что по зависимостям и его втащило.


        1. grossws
          23.09.2015 20:55

          Я пока с библиотеками на rust не был знаком и появился интересный кандидат на рассмотрение)


  1. VolCh
    24.09.2015 07:39
    +1

        let me = Person {
            id: 0,
            name: "Михаил".to_string(),
            data: None
        };
    

    для id нельзя задать None?

    А «Михаил» не строка?!


    1. splav_asv
      24.09.2015 10:39
      +1

      А «Михаил» не строка?!

      Строковые константы имеют тип &'static str, т.е. являются string slice, а в структуре у поля тип String — heap-allocated string, изменяемая строка. Почему выбран именно он — отдельный вопрос, потому как для &'a str тоже есть трейт ToSql и в execute можно напрямую передавать &'static str.


      1. mkpankov
        24.09.2015 12:07

        String выбран, чтобы не возиться с временами жизни в прототипе и не пугать лишний раз новичков.


    1. stepik777
      24.09.2015 11:48

      для id нельзя задать None?
      id объявлен в структуре как i32, это обычное 32-битное знаковое число, у которого нет такого значения как None. Чтобы было None, нужно его объявить как Option<i32>, тогда возможными значениями были бы None, Some(0) (но не 0), Some(1), Some(-5), Some(99845) и т.д.

      А "Михаил" не строка?!
      Разница примерно как между const char* и std::string в С++, там можно написать std::string("Михаил"), только С++ может сам добавить неявный вызов этого конструктора. В Rust "Михаил" имеет тип &str, он содержит указатель на начало utf-8 строки и её длину, to_string() преобразует его в String.


    1. mkpankov
      24.09.2015 12:06
      -1

      Уже написали несколько объяснений, но я попробую ещё раз, потому что про строки любят спрашивать :)

      «Михаил» — это строковый литерал, это не «объект». Его нельзя изменять, например. Это «unboxed» значение, и to_string превращает его в boxed значение на куче.


  1. ComodoHacker
    24.09.2015 12:42

    Хабру пора добавить подсветку для Rust.


    1. mkpankov
      24.09.2015 12:56

      Она есть, просто косячная.