Emscripten e npm

Como você integra o WebAssembly a essa configuração? Neste artigo, vamos resolver isso usando C/C e Emscripten como exemplo.

WebAssembly (Wasm) é frequentemente como um primitivo de desempenho ou uma forma de executar seu código base de código na Web. Com o squoosh.app, queríamos mostrar que há pelo menos uma terceira perspectiva para o Wasm: usar a enorme ecossistemas de outras linguagens de programação. Com Emscripten, use código C/C , O Rust tem suporte para Wasm integrado, e o recurso Go está trabalhando nisso também. estou que muitos outros idiomas virão.

Nesses cenários, o Wasm não é o elemento central do app, mas sim um quebra-cabeça. pedaço: mais um módulo. Seu aplicativo já tem JavaScript, CSS, recursos de imagem, sistema de build centrado na Web e talvez até mesmo um framework como o React. Como você integrar o WebAssembly nessa configuração? Neste artigo, vamos abordar isso usando C/C e Emscripten como exemplo.

Docker

Achei o Docker inestimável ao trabalhar com a Emscripten. C/C As bibliotecas costumam ser criadas para funcionar com o sistema operacional em que são criadas. É incrivelmente útil ter um ambiente consistente. Com o Docker, você tem uma virtualizado do Linux, que já está configurado para funcionar com a Emscripten e tem todas as ferramentas e dependências instaladas. Se faltar algo, basta instalá-lo sem se preocupar com como ele afeta sua própria máquina outros projetos. Se algo der errado, jogue o contêiner e comece de novo. Se ele funcionar uma vez, pode ter certeza de que continuará funcionando e a produzir resultados idênticos.

O Docker Registry tem um arquivo Emscripten imagem de trzeci que tenho usado bastante.

Integração com npm

Na maioria dos casos, o ponto de entrada para um projeto da Web é o package.json: Por convenção, a maioria dos projetos pode ser criada com npm install && npm run build.

Em geral, os artefatos de build produzidos pela Emscripten (um .js e um .wasm) ) deve ser tratado apenas como outro módulo JavaScript e como ativo. O arquivo JavaScript pode ser processado por um bundler como webpack ou rollup, e o arquivo wasm deve ser tratado como qualquer outro ativo binário maior, como de imagens de contêiner.

Dessa forma, os artefatos de compilação Emscripten precisam ser construídos antes da O processo de build é iniciado:

{
    "name": "my-worldchanging-project",
    "scripts": {
    "build:emscripten": "docker run --rm -v $(pwd):/src trzeci/emscripten
./build.sh",
    "build:app": "<the old build command>",
    "build": "npm run build:emscripten && npm run build:app",
    // ...
    },
    // ...
}

A nova tarefa build:emscripten pode invocar o Emscripten diretamente, mas mencionei antes, recomendo usar o Docker para garantir que o ambiente de build seja consistentes.

docker run ... trzeci/emscripten ./build.sh diz ao Docker para ativar um novo contêiner usando a imagem trzeci/emscripten e execute o comando ./build.sh. build.sh é um script de shell que você vai criar a seguir. --rm diz Docker para excluir o contêiner após a conclusão da execução. Dessa forma, você não cria uma coleção de imagens de máquina desatualizadas ao longo do tempo. -v $(pwd):/src significa que você quer que o Docker seja "espelhado" diretório atual ($(pwd)) para /src dentro o contêiner. Todas as mudanças feitas nos arquivos do diretório /src dentro da contêiner será espelhado no projeto real. Esses diretórios espelhados são chamadas de "montagens de vinculação".

Vamos dar uma olhada em build.sh:

#!/bin/bash

set -e

export OPTIMIZE="-Os"
export LDFLAGS="${OPTIMIZE}"
export CFLAGS="${OPTIMIZE}"
export CXXFLAGS="${OPTIMIZE}"

echo "============================================="
echo "Compiling wasm bindings"
echo "============================================="
(
    # Compile C/C   code
    emcc \
    ${OPTIMIZE} \
    --bind \
    -s STRICT=1 \
    -s ALLOW_MEMORY_GROWTH=1 \
    -s MALLOC=emmalloc \
    -s MODULARIZE=1 \
    -s EXPORT_ES6=1 \
    -o ./my-module.js \
    src/my-module.cpp

    # Create output folder
    mkdir -p dist
    # Move artifacts
    mv my-module.{js,wasm} dist
)
echo "============================================="
echo "Compiling wasm bindings done"
echo "============================================="

Há muito o que discutir aqui!

set -e coloca o shell no modo "fail fast" modo Se algum comando no script retornar um erro, todo o script será abortado imediatamente. Isso pode ser incrivelmente útil, pois o último resultado do script será sempre um sucesso ou o erro que causou a falha do build.

Com as instruções export, você define os valores de alguns parâmetros de ambiente variáveis. Elas permitem que você passe parâmetros adicionais de linha de comando para o comando compilador (CFLAGS), o compilador C (CXXFLAGS) e o vinculador (LDFLAGS). Todos recebem as configurações do otimizador via OPTIMIZE para garantir que tudo é otimizado da mesma maneira. Há alguns valores possíveis para a variável OPTIMIZE:

  • -O0: não faz otimização. Nenhum código morto é eliminado, e o Emscripten também não reduz o código JavaScript que emite. Bom para depuração.
  • -O3: otimize de forma agressiva para melhorar o desempenho.
  • -Os: otimize de forma intensa para desempenho e tamanho como secundário. critério.
  • -Oz: otimize de forma agressiva em relação ao tamanho, sacrificando o desempenho, se necessário.

Para a Web, recomendamos principalmente o -Os.

O comando emcc tem uma infinidade de opções próprias. Observe que emcc é deve ser uma "substituição simples" para compiladores como GCC ou clang. Então, tudo que você precisa saber no GCC provavelmente serão implementadas por emcc como muito bem. A flag -s é especial porque permite configurar o Emscripten especificamente. Todas as opções disponíveis podem ser encontradas na settings.js, mas esse arquivo pode sobrecarregar tudo. Aqui está uma lista das bandeiras Emscripten que considero mais importantes para os desenvolvedores Web:

  • O --bind ativa embind.
  • -s STRICT=1 descarta o suporte a todas as opções de build descontinuadas. Isso garante que seu código crie de maneira compatível com versões futuras.
  • -s ALLOW_MEMORY_GROWTH=1 permite que a memória aumente automaticamente se necessários. No momento em que este artigo foi escrito, o Emscripten vai alocar 16 MB de memória inicialmente. À medida que seu código aloca blocos de memória, essa opção decide se essas operações farão com que todo o módulo Wasm falhe quando a memória for exausto, ou se o código agrupador tiver permissão para expandir a memória total para para acomodar a alocação.
  • -s MALLOC=... escolhe qual implementação de malloc() usar. emmalloc é uma implementação pequena e rápida de malloc() especificamente para o Emscripten. A A alternativa é a dlmalloc, uma implementação completa de malloc(). Você só precisa Mude para dlmalloc se estiver alocando muitos objetos pequenos. com frequência ou se quiser usar linhas de execução.
  • O -s EXPORT_ES6=1 transforma o código JavaScript em um módulo ES6 com uma uma exportação padrão que funcione com qualquer bundler. Também requer -s MODULARIZE=1 para ser definido.

As sinalizações a seguir nem sempre são necessárias ou úteis apenas para depuração. finalidades:

  • -s FILESYSTEM=0 é uma flag relacionada ao Emscripten e à capacidade de emular um sistema de arquivos para você quando seu código C/C usar operações de sistema de arquivos. Ele analisa o código que compila para decidir se deve incluir os emulação de sistema de arquivos no código cola ou não. Às vezes, no entanto, a análise pode errar, e você paga uns 70 KB grandes em cola adicional. para uma emulação de sistema de arquivos que talvez você não precise. Com -s FILESYSTEM=0, é possível forçar o Emscripten a não incluir esse código.
  • O -g4 vai fazer com que o Emscripten inclua informações de depuração nos .wasm e também emite um arquivo de mapas de origem para o módulo Wasm. Você pode ler mais em de depuração com o Emscripten na seção de depuração .

Pronto! Para testar essa configuração, vamos criar um pequeno my-module.cpp:

    #include <emscripten/bind.h>

    using namespace emscripten;

    int say_hello() {
      printf("Hello from your wasm module\n");
      return 0;
    }

    EMSCRIPTEN_BINDINGS(my_module) {
      function("sayHello", &say_hello);
    }

E uma index.html:

    <!doctype html>
    <title>Emscripten   npm example</title>
    Open the console to see the output from the wasm module.
    <script type="module">
    import wasmModule from "./my-module.js";

    const instance = wasmModule({
      onRuntimeInitialized() {
        instance.sayHello();
      }
    });
    </script>

(Aqui está um gist (em inglês) que contém todos os arquivos.

Para criar tudo, execute

$ npm install
$ npm run build
$ npm run serve

Ao navegar para localhost:8080, você verá a seguinte saída no Console do DevTools:

DevTools mostrando uma mensagem impressa via C   e Emscripten.

Como adicionar código C/C como uma dependência

Se você quiser criar uma biblioteca C/C para seu app da Web, será necessário que o código seja parte do seu projeto. É possível adicionar o código ao repositório do projeto manualmente ou você também pode usar o npm para gerenciar esse tipo de dependências. Digamos que eu quero usar libvpx no meu webapp. libvpx é uma biblioteca C para codificar imagens com VP8, o codec usado em arquivos .webm. No entanto, a libvpx não está no npm e não tem um package.json, então não posso e instalá-lo usando npm diretamente.

Para sair desse enigma, há napa. O napa permite que você instale URL do repositório como uma dependência na sua pasta node_modules.

Instale o napa como uma dependência:

$ npm install --save napa

e execute napa como um script de instalação:

{
// ...
"scripts": {
    "install": "napa",
    // ...
},
"napa": {
    "libvpx": "git https://github.com/webmproject/libvpx"
}
// ...
}

Quando você executa npm install, o napa cuida da clonagem do libvpx do GitHub repositório em node_modules com o nome libvpx.

Agora você pode estender seu script de compilação para criar o libvpx. O libvpx usa configure e make sejam criados. A Emscripten pode ajudar a garantir que configure e make usam o compilador do Emscripten. Para isso, há o wrapper comandos emconfigure e emmake:

# ... above is unchanged ...
echo "============================================="
echo "Compiling libvpx"
echo "============================================="
(
    rm -rf build-vpx || true
    mkdir build-vpx
    cd build-vpx
    emconfigure ../node_modules/libvpx/configure \
    --target=generic-gnu
    emmake make
)
echo "============================================="
echo "Compiling libvpx done"
echo "============================================="

echo "============================================="
echo "Compiling wasm bindings"
echo "============================================="
# ... below is unchanged ...

Uma biblioteca C/C é dividida em duas partes: os cabeçalhos (normalmente .h ou .hpp) que definem as estruturas de dados, classes, constantes etc. que um expõe a biblioteca e a biblioteca real (tradicionalmente, arquivos .so ou .a). Para usar a constante VPX_CODEC_ABI_VERSION da biblioteca no código, você terá para incluir os arquivos principais da biblioteca usando uma instrução #include:

#include "vpxenc.h"
#include <emscripten/bind.h>

int say_hello() {
    printf("Hello from your wasm module with libvpx %d\n", VPX_CODEC_ABI_VERSION);
    return 0;
}

O problema é que o compilador não sabe onde procurar por vpxenc.h. É para isso que serve a flag -I. Ele informa ao compilador quais diretórios verifique se há arquivos de cabeçalho. Além disso, também é necessário fornecer ao compilador a arquivo de biblioteca real:

# ... above is unchanged ...
echo "============================================="
echo "Compiling wasm bindings"
echo "============================================="
(
    # Compile C/C   code
    emcc \
    ${OPTIMIZE} \
    --bind \
    -s STRICT=1 \
    -s ALLOW_MEMORY_GROWTH=1 \
    -s ASSERTIONS=0 \
    -s MALLOC=emmalloc \
    -s MODULARIZE=1 \
    -s EXPORT_ES6=1 \
    -o ./my-module.js \
    -I ./node_modules/libvpx \
    src/my-module.cpp \
    build-vpx/libvpx.a

# ... below is unchanged ...

Se você executar npm run build agora, vai notar que o processo cria um novo .js. e um novo arquivo .wasm e que a página de demonstração vai gerar a constante:

DevTools
mostrando uma versão ABI de libvpx impressa via Emscripten.

O processo de build leva muito tempo. O motivo os tempos de build longos podem variar. No caso do libvpx, isso leva muito tempo ele compila um codificador e um decodificador para o VP8 e o VP9 sempre que você executa ao comando de compilação, mesmo que os arquivos de origem não tenham sido alterados. Mesmo um pequeno no my-module.cpp vai levar muito tempo para ser criada. Seria muito é útil manter os artefatos de compilação do libvpx por perto quando forem criado pela primeira vez.

Uma maneira de fazer isso é usando variáveis de ambiente.

# ... above is unchanged ...
eval $@

echo "============================================="
echo "Compiling libvpx"
echo "============================================="
test -n "$SKIP_LIBVPX" || (
    rm -rf build-vpx || true
    mkdir build-vpx
    cd build-vpx
    emconfigure ../node_modules/libvpx/configure \
    --target=generic-gnu
    emmake make
)
echo "============================================="
echo "Compiling libvpx done"
echo "============================================="
# ... below is unchanged ...

(Confira um gist que contém todos os arquivos.

Com o comando eval, é possível definir variáveis de ambiente transmitindo parâmetros. ao script de compilação. O comando test ignora a criação do libvpx se $SKIP_LIBVPX é definido (com qualquer valor).

Agora você pode compilar o módulo, mas sem recriar o libvpx:

$ npm run build:emscripten -- SKIP_LIBVPX=1

Como personalizar o ambiente de build

Às vezes, as bibliotecas dependem de outras ferramentas para a criação. Se essas dependências estiverem ausentes no ambiente de build fornecido pela imagem Docker, você precisará e adicioná-las você mesmo. Como exemplo, digamos que você também queira criar o documentação do libvpx usando doxygen. O doxigênio não é disponível no contêiner do Docker, mas é possível instalá-lo usando apt.

Se você fizer isso no build.sh, faça o download e a instalação outra vez. doxygen toda vez que quiser criar sua biblioteca. Não apenas isso seria desperdício, mas também impediria você de trabalhar em seu projeto enquanto estiver off-line.

Aqui faz sentido criar sua própria imagem Docker. As imagens Docker são criadas criando um Dockerfile que descreve as etapas de build. Os Dockerfiles são poderosas e têm muitos comandos, mas a maioria dos é possível usar apenas FROM, RUN e ADD. Nesse caso:

FROM trzeci/emscripten

RUN apt-get update && \
    apt-get install -qqy doxygen

Com FROM, é possível declarar qual imagem do Docker você quer usar como ponto de partida ponto Escolhi trzeci/emscripten como base: a imagem que você está usando o tempo todo. Com RUN, você instrui o Docker a executar comandos do shell dentro do contêiner do Docker. As mudanças que esses comandos fazem no contêiner agora são parte do a imagem Docker. Para verificar se a imagem Docker foi criada e está disponível antes de executar o build.sh, você precisa ajustar os package.json pouco:

{
    // ...
    "scripts": {
    "build:dockerimage": "docker image inspect -f '.' mydockerimage || docker build -t mydockerimage .",
    "build:emscripten": "docker run --rm -v $(pwd):/src mydockerimage ./build.sh",
    "build": "npm run build:dockerimage && npm run build:emscripten && npm run build:app",
    // ...
    },
    // ...
}

(Confira um gist que contém todos os arquivos.

Isso criará sua imagem Docker, mas somente se ela ainda não tiver sido criada. Depois, tudo será executado como antes, mas agora o ambiente de build tem a propriedade doxygen disponível, o que fará com que a documentação de libvpx seja criada como muito bem.

Conclusão

Não surpreende que o código C/C e o npm não sejam uma opção natural, mas você pode fazê-lo funcionar confortavelmente com algumas ferramentas adicionais e o isolamento fornecidos pelo Docker. Essa configuração não funcionará em todos os projetos, mas é um que você possa ajustar de acordo com suas necessidades. Se você tiver melhorias, compartilhe.

Apêndice: como usar camadas de imagem do Docker

Uma solução alternativa é encapsular mais desses problemas com o Docker e a abordagem inteligente do Docker para armazenamento em cache. O Docker executa os Dockerfiles passo a passo e atribui ao resultado de cada etapa uma imagem própria. Essas imagens intermediárias são muitas vezes chamadas de "camadas". Se um comando em um Dockerfile não tiver sido alterado, o Docker essa etapa não será executada novamente quando você recompilar o Dockerfile. Em vez ela vai reutilizar a camada da última vez que a imagem foi criada.

Anteriormente, era necessário tentar não recriar o libvpx todas as vezes durante a criação do seu app. Em vez disso, você pode mover as instruções de criação para libvpx do build.sh para o Dockerfile para usar o armazenamento em cache do Docker mecanismo de atenção:

FROM trzeci/emscripten

RUN apt-get update && \
    apt-get install -qqy doxygen git && \
    mkdir -p /opt/libvpx/build && \
    git clone https://github.com/webmproject/libvpx /opt/libvpx/src
RUN cd /opt/libvpx/build && \
    emconfigure ../src/configure --target=generic-gnu && \
    emmake make

(Confira um gist que contém todos os arquivos.

Você precisa instalar o git e clonar o libvpx manualmente, porque não vincular montagens ao executar docker build. Como efeito colateral, não é necessário mais um napa.