웹 어셈블리로 브라우저에서 Rust 실행하기



게시물Running Rust in the Browser with Web AssemblyQvault에 처음 등장했습니다.

저는 최근에 Qvault app 에 대한 Rust 과정을 진행하고 있습니다. 보다 매력적인 과정을 작성하기 위해 학생들이 브라우저에서 바로 코드를 작성하고 실행할 수 있기를 바랍니다. 이 주제에 대한 이전 게시물에서 배웠듯이 서버에서 코드를 샌드박스로 실행하는 가장 쉬운 방법은 서버에서 코드를 실행하지 않는 것입니다. 왼쪽 단계에서 Web Assembly에 들어갑니다.

작동 방식에 관심이 없고 그냥 사용해 보고 싶은 분들은 데모를 확인하십시오: Rust WASM Playground .

작동 방식



아키텍처는 매우 간단합니다.
  • 사용자가 브라우저에 코드를 작성함
  • 브라우저에서 서버로 코드 전송
  • 서버가 약간의 접착제를 추가하고 코드를 WASM에 컴파일합니다
  • .
  • 서버가 WASM 바이트 또는 컴파일러 오류를 다시 브라우저로 보냅니다
  • .
  • 브라우저가 WASM을 실행하고 콘솔 출력을 표시하거나 컴파일러 오류를 표시함

  • 코드를 작성하고 서버에 전달하는 것은 설명이 필요하지 않습니다. 가져오기 API와 결합된 간단한 텍스트 편집기입니다. 우리가 하는 첫 번째 흥미로운 일은 서버에서 코드를 컴파일하는 것입니다.

    코드 컴파일



    Qvault의 서버는 Go로 작성되었습니다. 다음 서명이 있는 간단한 HTTP 처리기가 있습니다.

    func (cfg config) compileRustHandler(w http.ResponseWriter, r *http.Request)
    


    함수 시작 시 JSON 본문에 제공된 코드를 언마샬링합니다.

    type parameters struct {
        Code string
    }
    
    decoder := json.NewDecoder(r.Body)
    params := parameters{}
    err := decoder.Decode(&params)
    if err != nil {
        respondWithError(w, 500, "Couldn't decode parameters")
        return
    }
    


    다음으로 Rust 프로젝트를 생성하기 위해 "스크래치 패드"로 사용할 임시 폴더를 디스크에 생성합니다.

    usr, err := user.Current()
    if err != nil {
        respondWithError(w, 500, "Couldn't get system user")
        return
    }
    workingDir := filepath.Join(usr.HomeDir, ".wasm", uuid.New().String())
    err = os.MkdirAll(workingDir, os.ModePerm)
    if err != nil {
        respondWithError(w, 500, "Couldn't create directory for compilation")
        return
    }
    defer func() {
        err = os.RemoveAll(workingDir)
        if err != nil {
            respondWithError(w, 500, "Couldn't clean up code from compilation")
            return
        }
    }()
    


    보시다시피 홈 디렉토리의 .wasm/uuid 경로 아래에 프로젝트를 생성합니다. 또한 defer 이 요청을 처리할 때 이 폴더를 삭제하는 os.RemoveAll 기능이 있습니다.

    다음으로 운영 체제 명령을 실행하고 stderr이 있는 경우 이를 반환하는 도우미 함수를 설정합니다.

    func runCmd(workingDir, name string, args ...string) error {
        cmd := exec.Command(name, args...)
        cmd.Dir = filepath.Join(workingDir)
        stdErrReader, err := cmd.StderrPipe()
        if err != nil {
            return err
        }
        if err := cmd.Start(); err != nil {
            return err
        }
        stdErr, err := ioutil.ReadAll(stdErrReader)
        if err != nil {
            return err
        }
        if err := cmd.Wait(); err != nil {
            if len(stdErr) > 0 {
                return fmt.Errorf("%s", stdErr)
            }
            return err
        }
        return nil
    }
    


    다음으로 (다시 HTTP 처리기에서) 해당 함수를 사용하여 임시 디렉토리에 새 Rust 프로젝트를 생성합니다.

    const projectName = "main"
    err = runCmd(workingDir, "cargo", "new", projectName)
    if err != nil {
        respondWithError(w, 500, err.Error())
        return
    }
    


    그런 다음 디스크에 제공된 코드를 작성해야 합니다. 하지만 그 전에 접착제를 추가해야 합니다. 글루 코드는 Rust의 인쇄 매크로를 재정의하여 stdout을 캡처하는 JavaScript 함수를 제공할 수 있습니다. 이 접착제를 공개해 주셔서 감사합니다Sterling Demille.

    // copied from https://github.com/DeMille/wasm-glue
    #![feature(set_stdio)]
    #![feature(panic_col)]
    
    use std::ffi::CString;
    use std::os::raw::c_char;
    use std::fmt;
    use std::fmt::Write;
    use std::panic;
    use std::io;
    
    // these are the functions you'll need to privide with JS
    extern {
        fn print(ptr: *const c_char);
        fn eprint(ptr: *const c_char);
        fn trace(ptr: *const c_char);
    }
    
    fn _print(buf: &str) -> io::Result<()> {
        let cstring = CString::new(buf)?;
    
        unsafe {
            print(cstring.as_ptr());
        }
    
        Ok(())
    }
    
    fn _eprint(buf: &str) -> io::Result<()> {
        let cstring = CString::new(buf)?;
    
        unsafe {
            eprint(cstring.as_ptr());
        }
    
        Ok(())
    }
    
    /// Used by the "print" macro
    #[doc(hidden)]
    pub fn _print_args(args: fmt::Arguments) {
        let mut buf = String::new();
        let _ = buf.write_fmt(args);
        let _ = _print(&buf);
    }
    
    /// Used by the "eprint" macro
    #[doc(hidden)]
    pub fn _eprint_args(args: fmt::Arguments) {
        let mut buf = String::new();
        let _ = buf.write_fmt(args);
        let _ = _eprint(&buf);
    }
    
    /// Overrides the default "print!" macro.
    #[macro_export]
    macro_rules! print {
        ($($arg:tt)*) => ($crate::_print_args(format_args!($($arg)*)));
    }
    
    /// Overrides the default "eprint!" macro.
    #[macro_export]
    macro_rules! eprint {
        ($($arg:tt)*) => ($crate::_eprint_args(format_args!($($arg)*)));
    }
    
    type PrintFn = fn(&str) -> io::Result<()>;
    
    struct Printer {
        printfn: PrintFn,
        buffer: String,
        is_buffered: bool,
    }
    
    impl Printer {
        fn new(printfn: PrintFn, is_buffered: bool) -> Printer {
            Printer {
                buffer: String::new(),
                printfn,
                is_buffered,
            }
        }
    }
    
    impl io::Write for Printer {
        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
            self.buffer.push_str(&String::from_utf8_lossy(buf));
    
            if !self.is_buffered {
                (self.printfn)(&self.buffer)?;
                self.buffer.clear();
    
                return Ok(buf.len());
            }
    
            if let Some(i) = self.buffer.rfind('\n') {
                let buffered = {
                    let (first, last) = self.buffer.split_at(i);
                    (self.printfn)(first)?;
    
                    String::from(&last[1..])
                };
    
                self.buffer.clear();
                self.buffer.push_str(&buffered);
            }
    
            Ok(buf.len())
        }
    
        fn flush(&mut self) -> io::Result<()> {
            (self.printfn)(&self.buffer)?;
            self.buffer.clear();
    
            Ok(())
        }
    }
    
    /// Sets a line-buffered stdout, uses your JavaScript "print" function
    pub fn set_stdout() {
        let printer = Printer::new(_print, true);
        io::set_print(Some(Box::new(printer)));
    }
    
    /// Sets an unbuffered stdout, uses your JavaScript "print" function
    pub fn set_stdout_unbuffered() {
        let printer = Printer::new(_print, false);
        io::set_print(Some(Box::new(printer)));
    }
    
    /// Sets a line-buffered stderr, uses your JavaScript "eprint" function
    pub fn set_stderr() {
        let eprinter = Printer::new(_eprint, true);
        io::set_panic(Some(Box::new(eprinter)));
    }
    
    /// Sets an unbuffered stderr, uses your JavaScript "eprint" function
    pub fn set_stderr_unbuffered() {
        let eprinter = Printer::new(_eprint, false);
        io::set_panic(Some(Box::new(eprinter)));
    }
    
    /// Sets a custom panic hook, uses your JavaScript "trace" function
    pub fn set_panic_hook() {
        panic::set_hook(Box::new(|info| {
            let file = info.location().unwrap().file();
            let line = info.location().unwrap().line();
            let col = info.location().unwrap().column();
    
            let msg = match info.payload().downcast_ref::<&'static str>() {
                Some(s) => *s,
                None => {
                    match info.payload().downcast_ref::<String>() {
                        Some(s) => &s[..],
                        None => "Box<Any>",
                    }
                }
            };
    
            let err_info = format!("Panicked at '{}', {}:{}:{}", msg, file, line, col);
            let cstring = CString::new(err_info).unwrap();
    
            unsafe {
                trace(cstring.as_ptr());
            }
        }));
    }
    
    /// Sets stdout, stderr, and a custom panic hook
    pub fn hook() {
        set_stdout();
        set_stderr();
        set_panic_hook();
    }
    


    우리가 해야 할 일은 그 글루 코드를 사용자 제공 코드에 연결하고 hook() 에서 첫 번째로 main() 를 호출하는 것입니다.

    func writeRustToDisk(workingDir, projectName, code string) error {
        // remove old code
        codePath := filepath.Join(workingDir, projectName, "src", "main.rs")
        os.Remove(codePath)
    
        // create the new file
        f, err := os.Create(codePath)
        if err != nil {
            return errors.New("Couldn't open code file for compilation")
        }
        defer f.Close()
    
        // write the glue
        _, err = f.WriteString(rustGlue)
        if err != nil {
            return errors.New("Couldn't write code to file for compilation")
        }
    
        // add the hook
        code = addHook(code)
    
        // write the rest of the code
        dat := []byte(code)
        _, err = f.Write(dat)
        if err != nil {
            return errors.New("Couldn't write code to file for compilation")
        }
        return nil
    }
    


    여기서 rustGlue는 이전 단계의 접착제를 포함하는 문자열 상수이고 addHook는 정규식을 사용하여 hook() 호출을 적절하게 삽입하는 함수입니다.

    func addHook(code string) string {
        regex := regexp.MustCompile(`(fn\s*main\(\)\s*)\{`)
        return regex.ReplaceAllString(code, `fn main(){hook();`)
    }
    


    다음으로 가장 중요한 컴파일 단계:

    err = runCmd(
        filepath.Join(workingDir, projectName),
        "cargo",
        "+nightly",
        "build",
        "--target",
        "wasm32-unknown-unknown",
        "--release",
    )
    if err != nil {
        errString := err.Error()
        fmt.Println(errString)
        parts := strings.Split(errString, workingDir)
        if len(parts) < 2 {
            respondWithError(w, 500, errString)
            return
        }
        respondWithError(w, 400, parts[1])
        return
    }
    


    대상이 cargo build인 간단한 WASM입니다. 오류가 있는 경우 프런트엔드로 다시 보내기 전에 일부 파일 시스템 정보를 제거합니다.
    wasm-gc를 사용하여 빌드를 최적화합니다.

    err = runCmd(
        filepath.Join(workingDir, projectName),
        "wasm-gc",
        "target",
        "wasm32-unknown-unknown",
        "release",
        "main.wasm",
    )
    if err != nil {
        respondWithError(w, 500, err.Error())
        return
    }
    


    마지막으로 WASM을 원시 바이트로 프런트엔드로 다시 보냅니다.

    dat, err := ioutil.ReadFile(filepath.Join(workingDir, projectName, "target", "wasm32-unknown-unknown", "release", "main.wasm"))
    if err != nil {
        respondWithError(w, 500, err.Error())
        return
    }
    w.Write(dat)
    


    프런트엔드 - WASM 번들 실행



    Rust 프런트 엔드는 Go 프런트 엔드와 많은 유사점을 가지고 있습니다. 둘 다 웹 작업자를 사용하여 브라우저에서 잠재적으로 비용이 많이 드는 코드를 실행하는 사용자 경험을 최적화합니다. 그 방법을 따라잡으려면 제 web workers explanation here에서 읽어보세요.

    여기서 주요 차이점은 rust_worker.js 파일에 있습니다. 참조 문서의 go_worker.js에 해당하는 항목:

    // send(line) sends a single line of stdout back to the browser to // be rendered in the on-screen console
    function send(line){
      postMessage({
        message: readString(line)
      });
    }
    
    // keep a WebAssembly memory reference for readString
    let memory;
    
    // read a null terminated c string at a wasm memory buffer index
    function readString(ptr) {
      const view = new Uint8Array(memory.buffer);
    
      let end = ptr;
      while (view[end]) ++end;
    
      const buf = new Uint8Array(view.subarray(ptr, end));
      return (new TextDecoder()).decode(buf);
    }
    
    // addEventListener is a handler that get's called whenever the
    // main thread (editor) sends us some WASM to execute
    addEventListener('message', async (e) => {
      const result = await WebAssembly.instantiate(e.data, {
        // here we define the print, eprint, and trace
        // functions as specified in the glue from above
        // they just send stdout back using the send function
        env: {
          print(ptr){
            send(ptr);
          },
          eprint(ptr) {
            send(ptr);
          },
          trace(ptr) {
            send(ptr);
          }
        }
      });
    
      memory = result.instance.exports.memory;
    
      // run the main() function of the WASM code 
      await result.instance.exports.main();
    
      // let the editor know we've sent all the output and have
      // finished
      postMessage({
        done: true
      });
    }, false);
    


    읽어 주셔서 감사합니다!



    질문이나 의견이 있으면 Twitter에서 팔로우하세요.

    좀 가져가 coding courses on our new platform

    Subscribe 더 많은 프로그래밍 기사를 보려면 뉴스레터로

    좋은 웹페이지 즐겨찾기