Coder Social home page Coder Social logo

wasm-cppjieba's People

Contributors

yingshandeng avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar

Forkers

jackysz

wasm-cppjieba's Issues

WebAssembly 在在线文档分词场景中的解决方案

在浏览器页面或者文字编辑器(Word, WPS,Pages,备忘录...)中,双击选中词组,三击选中段落,是一个非常基础的操作体验。其中双击选中词组功能基本都是通过客户端内置中文分词算法实现的,分词结果有时可能不尽如人意,但还算差强人意。而在线文档作为 web 应用,双击选词功能在几家竞品的对比如下:

品类 实现原理 双击选词支持情况
谷歌文档 非contenteditable,自行实现排版引擎 不支持(双击选中单字)
腾讯文档 非contenteditable,自行实现排版引擎 不支持(双击选中单字)
石墨文档 contenteditable 支持(浏览器提供的分词能力)

谷歌文档和腾讯文档的在线编辑器,采用自行实现排版引擎的方式实现,其中编辑器中的光标,选区,都是自行绘制的,禁止了浏览器的默认行为,即 event.preventDefault()。所以在鼠标单击移动光标,双击选词,三击选段落都不是浏览器默认行为,所以在双击选词这里,因为暂时没有分词能力,不能支持双击选词,只能简单处理,选中单字。

为了支持双击选词,就需要在 web 应用中引入分词算法,并且关注分词的性能和准确性。开源社区非常有名的分词库 fxsjy/jieba,分词效果和性能都很好,且有着各种语言的实现版本,所以我们可以考虑将其 结巴分词 C++ 版本 编译成 WebAssembly 在前端运行,处理文本分词。

先上代码:

注:在线文档中有两个场景涉及分词:1.鼠标双击选中词组; 2.键盘option+左右方向键跳过词组

理论知识和环境相关准备

过程拆解

  • 词典文件的离线存储
  • emscripten 编译 jieba 代码
  • JS 代码和 WASM 代码直接的相互调用

词典文件的离线存储

jieba 分词需要用到词典文件,文件体积较大,所以通过 XHR 请求获取后需要在 indexeddb 中缓存,这里涉及文件操作。emscripten 提供了两种方式来处理文件:

简单的说一下区别:前者 FS API 可以看做是 JS 的 API,而后者 Fetch API 可以看做是 C/C++ 的 API。二者都能实现你所需的文件操作需求,我采用的 FS API。

编译参数

文件操作

往 DB 中进行文件读写,其中 FS.syncfs(populate, callback) 方法非常关键,无论读写文件都必须在其回调中。注意其中第一个参数:

  • 读 idb 中的数据,是 true
  • 往 idb 中写数据,是 false

populate (bool) – true to initialize Emscripten’s file system data with the data from the file system’s persistent source, and false to save Emscripten`s file system data to the file system’s persistent source.

EM_ASM(
    FS.mkdir('/working');
    FS.mount(IDBFS, {}, '/working');
    FS.writeFile('/working/file1', 'foobar1');
    FS.writeFile('/working/file2', 'foobar2');
    FS.syncfs(false, function (err) {
        console.log(FS.readFile('/working/file1', { encoding: 'utf8' }));
    });
);

特别要注意,FS.syncfs(false, function (err) {}), false 表示写数据,属于 DB 覆盖式写数据,在写入数据之前会清空 DB。这一点比较奇怪。

例如我们在页面打开时,判断是否有字典丢失,XHR 拉取丢失的词典,再写入 DB 中。但是这种方式就不行!!!会把原有的字典清除。所以如果有词典丢失,则需要全部重新下载一遍,写入到 DB。

当然对于其中的读写文件,也可以使用 C/C++ 的方法来处理,如下:

  • C 方式的写文件:
#include <stdio.h>
#include <emscripten.h>

int main() {
    EM_ASM(
        FS.mkdir('/working');
        FS.mount(IDBFS, {}, '/working');
    );

    // 下面是写数据,将数字 0~9 写入到 data.txt 文件中
    FILE *fpWrite = fopen("/working/data.txt","w");
    if (fpWrite==NULL) {
        return 0;
    }
    for(int i=0;i<10;i++) {
        fprintf(fpWrite,"%d ",i);
    }
    fclose(fpWrite);

    EM_ASM(
        FS.syncfs(false, function (err) {
            console.log(FS.readFile('/working/data.txt', { encoding: 'utf8' }));
        });
    );
    return 0;
}
  • C++ 方式的读文件(注意:编译指令需要从 emcc 变成 em++)
#include <fstream>
#include <iostream>
#include <string>
#include <stdio.h>
#include <emscripten.h>
using namespace std;

extern "C" {
    void test() {
        std::ifstream file("working/file1");
        std::string line;
        getline(file, line);
        std::cout << "read: " << line << std::endl;
    }
}
int main() {
    EM_ASM(
        FS.mkdir('/working');
        FS.mount(IDBFS, {}, '/working');
        FS.writeFile('/working/file1', 'foobar1');
        FS.writeFile('/working/file2', 'foobar2');

        FS.syncfs(false, function (err) {
            // 以下两种调用方法都可以
            // Module.ccall('test', 'null', [], []);
            Module._test();
        });
    );
  return 0;
}

其中有两个点需要说明:

1、在 c++ 文件中 export 方法,需要在 extern "C"

The function executes a compiled C function from JavaScript and returns the result. C++ name mangling means that “normal” C++ functions cannot be called; the function must either be defined in a .c file or be a C++ function defined with extern "C".

2、在 JS 中调用 C 方法,有两种方式:

  • 调用方式:
    • ccall/cwrap: Module.ccall('test', 'null', [], [])
    • 直接调用(方法前需要添加下划线_): Module._test()
  • 编译参数:
    • 前者:-s 'EXTRA_EXPORTED_RUNTIME_METHODS=["ccall", "cwrap"]' -s EXPORTED_FUNCTIONS="['_test', '_main']"
    • 后者:-s EXPORTED_FUNCTIONS="['_test', '_main']"
  • 调用性能:后者优于前者

文档链接 Functions in the original source become JavaScript functions, so you can call them directly if you do type translations yourself — this will be faster than using ccall() or cwrap(), but a little more complicated.

这个是因为前者需要进行类型转换,而后者直接调用,是自己处理了类型转换。
举个例子: wasm-cppjieba.cpp 中提供了 void cutSentence(char* s) 分词方法,我是这样调用的:

function cutSentence(str) {
    var lengthBytes = lengthBytesUTF8(str)+1;
    var stringOnWasmHeap = Module._malloc(lengthBytes);
    stringToUTF8(str, stringOnWasmHeap, lengthBytes);

    Module._cutSentence(stringOnWasmHeap);
    Module._free(stringOnWasmHeap);
}

通过 FS.unlinkFS.analyzePath 可以删除文件和判断文件是否存在。

#include <emscripten.h>
int main() {
    EM_ASM(
        FS.mkdir('/working');
        FS.mount(IDBFS, {}, '/working');

        FS.writeFile('/working/file1', 'foobar1');
        FS.writeFile('/working/file2', 'foobar2');

        FS.syncfs(true, function (err) {
            // 判断文件是否存在
            console.log(FS.analyzePath('/working/file4').exists); // false
            if(FS.analyzePath('/working/file1').exists) {
                console.log('exist');

                FS.unlink('/working/file1'); // 删除文件
                FS.syncfs(false, function (err) {});
            }
        });
    );
  return 0;
}

词典文件读写流程

image.png
jieba 需要五个词典文件,这里采用串行 promise 方式处理 XHR 请求获取词典文件和写入 DB。
XHR 获取词典文件:

function xhrLoadDict(baseURL, dict) {
    var url = baseURL + dict;
    return new Promise(function(resolve, reject) {
        var request = new XMLHttpRequest;
        request.open("GET", url, true);
        request.responseType = "arraybuffer";
        request.url = url;
        request.onreadystatechange = function() {
            if (request.readyState == 4) {
                if (request.status == 200) {
                    resolve(request.response);
                } else {
                    reject('XHR LOAD FAIL: ' + request.status + ' : ' + request.url);
                }
            }
        };
        request.send();
    });
}

写入 DB 逻辑:

function writeDictToDB(arraybuf, dictPath) {
    return new Promise(function(resolve, reject) {
        var len = arraybuf.byteLength;
        var dataView = new DataView(arraybuf);
        var data = new Uint8Array(len);
        for (var i = 0; i < len; i++) {
            data[i] = dataView.getUint8(i);
        }
        var stream= FS.open(dictPath, 'w+');
        FS.write(stream, data, 0, data.length, 0);
        FS.close(stream);

        FS.syncfs(false, function (err) {
            if (err) {
                reject('WRITE TO DB FAIL: ' + dictPath);
            } else {
                resolve();
            }
        });
    });
}

DataView The DataView view provides a low-level interface for reading and writing multiple number types in a binary ArrayBuffer, without having to care about the platform's endianness.

最终效果

image.png

编译

观察 cppjieba 仓库代码,是使用 cmakemake 编译项目,那么可以通过修改项目里面 CMakeLists.txt 文件,来快速实现编译流程。

cmake, make, CMakeLists.txt, Makefile简介

PROJECT(CPPJIEBA)

CMAKE_MINIMUM_REQUIRED (VERSION 2.6)

INCLUDE_DIRECTORIES(
    ${PROJECT_SOURCE_DIR}/cppjieba/deps
    ${PROJECT_SOURCE_DIR}/cppjieba/include
)

if (CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
    set (CMAKE_INSTALL_PREFIX "/usr/local/cppjieba" CACHE PATH "default install path" FORCE )
endif()

ADD_DEFINITIONS(-O3 -Wall -g)
IF(APPLE) # mac os
    ADD_DEFINITIONS(-std=c++0x)
endif()

ADD_SUBDIRECTORY(cppjieba/deps)
ADD_EXECUTABLE(wasm-cppjieba src/wasm-cppjieba.cpp)

注意到最后一行,生成 src/wasm-cppjieba.cpp 的可执行文件。

在文档中 Integrating with a build system emscripten 提供了两个命令:emcmakeemmake,所以我们现在执行:

emcmake cmake ..
emake make

结果直接生成了 .js.wasm 文件,显然这个是不能用的,或者是有问题的。文档中提到:Run emmake with the normal make to generate linked LLVM bitcode. 但是没有找到 .bc 文件!在疑惑之际,发现这段话:

The file output from make might have a different suffix: .a for a static library archive, .so for a shared library, .o or .bc for object files (these file extensions are the same as gcc would use for the different types). Irrespective of the file extension, these files contain linked LLVM bitcode that emcc can compile into JavaScript in the final step. If the suffix is something else - like no suffix at all, or something like .so.1 - then you may need to rename the file before sending it to emcc.

生成可执行文件之前,编译器会先编译代码生成目标文件(.o),所以我们可以找一下这个 .o 后缀的文件。然后通过 emcc 把目标文件(.o) 编译成 .wasm 文件。最终在 build 目录下找到了:./CMakeFiles/wasm-cppjieba.dir/src/wasm-cppjieba.cpp.o,编译的问题也解决了。

注:这里应该是我 CMakeLists.txt 文件有问题,所以没有生成 .bc 文件,有大佬知道的麻烦指点一下!

相互调用

image.png

jieba 能在浏览器中运行之后,就需要设计相互调用的接口。wasm 需要 export 方法和 import 方法。

export 方法,供 JS 调用

通过编译参数指定 export 方法,例如:
-s EXPORTED_FUNCTIONS="['_main', '_checkDict', '_initJiebaInstance', '_cutSentence']",注意加下划线即可

import 方法,供回调使用

参考文档:Implement a C API in JavaScript

1、通过 mergeInto 注入需要 import 的方法

mergeInto(LibraryManager.library, {
    afterCheckJiebaDictsCallback: function(type) {
        // ...
    },
    afterInitJiebaCallback: function() {
        // ...
    },
    afterCutSentenceCallback: function(addr, length) {
      // ...
    },
    report: function(type, time) {
        // ...
    },
});

2、同时,如果是在 C++ 文件中,还需要在 extern "C" 声明这些方法

When using C++ you should encapsulate extern void my_js(); in an extern "C" {} block to prevent C++ name mangling:

extern "C" {
    extern void afterCheckJiebaDictsCallback(const char* type);
    extern void afterInitJiebaCallback(void);
    extern void afterCutSentenceCallback(uint8_t* data, int dataLength);
    extern void report(const char* type, double time);
}

3、通过编译参数 --js-library 指定文件,例如: --js-library ../src/wasm-cppjieba-library.js

woker 相关代码注入

在 emcc 编译选项中提供了自动生成 worker 代码的选项 --proxy-to-worker,但是生成的代码感觉冗余挺多,所以还是自己写吧。

使用 emcc 编译选项 --pre-js 可以注入代码。例如: --pre-js ../src/wasm-cppjieba-pre.js 即在 wasm-cppjieba-pre.js 文件中编写 worker 相关代码。

总结

通过 WebAssembly 将 jieba 分词库搬到 Web 前端运行,很好的解决了分词场景的痛点,性能和效果都相当不错,完全可以在生产环境中进行使用。这个例子也可以作为 WebAssembly 上手学习实践的例子。

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.