Asynchronous user scripts in pure Rust without frameworks and SMS

    Hello, Habr!

    Sometimes, when developing network services and user interfaces, one has to deal with rather complicated interaction scenarios containing branches and loops. Such scenarios do not fit into a simple state machine - it is not enough to store all the data in the session object, it is also advisable to track the route of the system to get into one state or another, and in some cases be able to go back a few steps, repeat the dialogue in a loop, and so on. .d. Previously, for this purpose, you had to develop your own data structures that imitate the stack machine, or even use third-party scripting languages. With the advent of asynchronous capabilities in almost all programming languages, it has become possible to write scripts in the same language in which the service is written. Scenario, with its own stack and local variables, in fact, it is a user session, that is, it stores both data and a route. For example, goroutine with blocking reading from the channel easily solves this problem, but firstly, the green thread is a pleasurenot free , and secondly, we write in Rust, where there are no green threads, but there are generators and async / await .

    For example, we’ll write a simple http-bot that displays an html form in the browser, asking the user questions until he answers that he feels good. The program is the simplest single-threaded http-server; we write the bot script in the form of a Rust generator. Let me remind you that JavaScript generators allow two-way data exchange, that is, inside the generator you can pass the question: my_generator.next (my_question);
    and return the response from it: yield my_response;
    In Rust, passing values ​​into the generator has not yet been implemented ( resume () functionhas no parameters, although there is a discussion to fix this), therefore, we organize the exchange of data through a shared cell, in which lies the structure with the received and sent data. The script of our bot is created by the create_scenario () function , which returns an instance of the generator, essentially the closure into which the parameter is moved - a pointer to the udata data cell . For each user session, we store our own cell with data and our own instance of the generator, with its own state of the stack and the values ​​of local variables.

    #[derive(Default, Clone)]
    struct UserData {
        sid: String,
        msg_in: String,
        msg_out: String,
        script: String,
    }
    type UserDataCell = Rc>;
    struct UserSession {
        udata: UserDataCell,
        scenario: Pin>>,
    }
    type UserSessions = HashMap;
    fn create_scenario(udata: UserDataCell) -> impl Generator {
        move || {
            let uname;
            let mut umood;
            udata.borrow_mut().msg_out = format!("Hi, what is you name ?");
            yield ();
            uname = udata.borrow().msg_in.clone();
            udata.borrow_mut().msg_out = format!("{}, how are you feeling ?", uname);
            yield ();
            'not_ok: loop {
                umood = udata.borrow().msg_in.clone();
                if umood.to_lowercase() == "ok" { break 'not_ok; }
                udata.borrow_mut().msg_out = format!("{}, think carefully, maybe you're ok ?", uname);
                yield ();
                umood = udata.borrow().msg_in.clone();
                if umood.to_lowercase() == "ok" { break 'not_ok; }
                udata.borrow_mut().msg_out = format!("{}, millions of people are starving, maybe you're ok ?", uname);
                yield ();
            }
            udata.borrow_mut().msg_out = format!("{}, good bye !", uname);
            return ();
        }
    }
    

    Each step of the script consists of simple actions - get a link to the contents of the cell, save user input in local variables, set the response text and give control to the outside, through yield . As can be seen from the code, our generator returns an empty tuple (), and all data is transmitted through a common cell with a reference counter Ref>. Inside the generator, you need to make sure that the borrowing of the contents of the borrow () cell does not cross the yield point , otherwise it will be impossible to update the data from the outside of the generator - therefore, unfortunately, you cannot write once at the beginning of the algorithm let udata_mut = udata.borrow_mut () , and you have to borrow a value after each yield.

    We implement our own event loop (reading from the socket), and for each incoming request we either create a new user session or find the existing one by sid, updating the data in it:

    let mut udata: UserData = read_udata(&mut stream);
    let mut sid = udata.sid.clone();
    let session;
    if sid == "" { //new session
        sid = rnd.gen::().to_string();
        udata.sid = sid.clone();
        let udata_cell = Rc::new(RefCell::new(udata));
        sessions.insert(
            sid.clone(),
            UserSession {
                udata: udata_cell.clone(),
                scenario: Box::pin(create_scenario(udata_cell)),
            }
        );
        session = sessions.get_mut(&sid).unwrap();
    } 
    else {
        match sessions.get_mut(&sid) {
            Some(s) => {
                session = s;
                session.udata.replace(udata);
            }
            None => {
                println!("unvalid sid: {}", &sid);
                continue;
            }
        }
    }
    

    Next, we transfer control inside the corresponding generator, and we update the updated data back to the socket. At the last step, when the entire script is completed, we delete the session from the hashmap and hide the input field from the html page using a js script.

    udata = match session.scenario.as_mut().resume() {
        GeneratorState::Yielded(_) => session.udata.borrow().clone(),
        GeneratorState::Complete(_) => {
            let mut ud = sessions.remove(&sid).unwrap().udata.borrow().clone();
            ud.script = format!("document.getElementById('form').style.display = 'none'");
            ud
        }
    };
    write_udata(&udata, &mut stream);
    

    The full working code is here:
    github.com/epishman/habr_samples/blob/master/chatbot/main.rs

    I apologize for the "collective" http parsing, which does not even support Cyrillic input, but everything was done using standard language tools, without frameworks, libraries, and sms. I don’t really like cloning strings, and the script itself doesn’t look quite compact due to the heavy use of borrow_mut () and clone () . Probably experienced rastamans will be able to simplify this (for example, using macros). The main thing is that the problem is solved by minimal means, and I hope that soon we will receive a complete set of asynchronous tools in a stable release.

    PS
    To compile you need a nightly build:
    rustup default nightly
    rustup update 
    

    Stack Overflow comrades helped me to deal with the generators:
    stackoverflow.com/questions/56460206/how-can-i-transfer-some-values-into-a-rust-generator-at-each-step

    Also popular now: