Emscripten ve npm

WebAssembly'i bu kuruluma nasıl entegre edersiniz? Bu makalede, örnek olarak C/C++ ve Emscripten'i kullanarak bu işlemi gerçekleştireceğiz.

WebAssembly (wasm), genellikle bir performans ilkelliği veya mevcut C++ kod tabanınızı web'de çalıştırmanın bir yolu olarak sunulur. squoosh.app ile, wasm için en azından üçüncü bir bakış açısı olduğunu göstermek istedik: diğer programlama dillerinin dev ekosistemlerinden yararlanma. Emscripten ile C/C++ kodu kullanabilirsiniz. Rust'ta yerleşik olarak wasm desteği vardır. Go ekibi de bu konu üzerinde çalışmaktadır. Diğer dillerin de ekleneceğinden eminiz.

Bu senaryolarda wasm, uygulamanızın ana unsuru değil, bir bulmaca parçasıdır: başka bir modüldür. Uygulamanızda JavaScript, CSS, resim öğeleri, web odaklı bir derleme sistemi ve hatta React gibi bir çerçeve zaten mevcuttur. WebAssembly'i bu kuruluma nasıl entegre edersiniz? Bu makalede, örnek olarak C/C++ ve Emscripten ile bu işlemi yapacağız.

Docker

Emscripten ile çalışırken Docker'ın çok faydalı olduğunu gördüm. C/C++ kitaplıkları genellikle yazıldıkları işletim sistemiyle çalışacak şekilde yazılır. Tutarlı bir ortam oluşturmak son derece faydalıdır. Docker ile, Emscripten ile çalışmak üzere önceden ayarlanmış ve tüm araç ve bağımlılıkların yüklü olduğu sanallaştırılmış bir Linux sistemi elde edersiniz. Eksik bir şey varsa kendi makinenizi veya diğer projelerinizi nasıl etkileyeceğinden endişelenmenize gerek kalmadan yükleyebilirsiniz. Bir sorun olursa kapsülü atın ve baştan başlayın. Bir kez işe yaradıysa çalışmaya devam edeceğinden ve aynı sonuçları vereceğinden emin olabilirsiniz.

Docker Kayıt Defteri'nde, trzeci tarafından oluşturulan ve yoğun olarak kullandığım bir Emscripten resmi var.

npm ile entegrasyon

Çoğu durumda, web projesinin giriş noktası npm'nin package.json paketidir. Kural olarak, çoğu proje npm install && npm run build ile derlenebilir.

Genel olarak, Emscripten tarafından üretilen derleme yapıları (.js ve .wasm dosyası) başka bir JavaScript modülü ve başka bir öğe olarak değerlendirilmelidir. JavaScript dosyası, webpack veya rollup gibi bir paketleyici tarafından işlenebilir. wasm dosyası ise resimler gibi diğer tüm büyük ikili öğeler gibi ele alınmalıdır.

Bu nedenle, "normal" derleme süreciniz başlamadan önce Emscripten derleme yapılarının oluşturulması gerekir:

{
    "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",
    // ...
    },
    // ...
}

Yeni build:emscripten görevi Emscripten'i doğrudan çağırabilir ancak daha önce de belirtildiği gibi, derleme ortamının tutarlı olduğundan emin olmak için Docker'ı kullanmanızı öneririz.

docker run ... trzeci/emscripten ./build.sh, Docker'a trzeci/emscripten görüntüsünü kullanarak yeni bir container oluşturmasını ve ./build.sh komutunu çalıştırmasını söyler. build.sh, bir sonraki adımda yazacağınız kabuk komut dosyasıdır. --rm, Docker'a çalışmayı bitirdikten sonra kapsayıcıyı silmesini söyler. Bu sayede, zaman içinde eski makine görüntüleri koleksiyonu oluşturmazsınız. -v $(pwd):/src, Docker'ın mevcut dizini ($(pwd)) kapsayıcı içindeki /src dizine "yansıtmasını" istediğiniz anlamına gelir. Kapsayıcı içindeki /src dizininde dosyalarda yaptığınız tüm değişiklikler gerçek projenize yansıtılır. Bu yansıtılmış dizinlere "bağlantı bağlama" denir.

build.sh'ye göz atalım:

#!/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 "============================================="

Burada çok fazla analiz edilecek konu var.

set -e, kabuğu "hızlı başarısız" moduna geçirir. Komut dosyasında hata döndüren komutlar varsa komut dosyasının tamamı hemen durdurulur. Komut dosyasının son çıkışı her zaman bir başarı mesajı veya derlemenin başarısız olmasına neden olan hata olacağından bu, son derece yararlı olabilir.

export ifadeleriyle birkaç ortam değişkeninin değerlerini tanımlarsınız. Bunlar, C derleyicisine (CFLAGS), C++ derleyicisine (CXXFLAGS) ve bağlayıcıya (LDFLAGS) ek komut satırı parametreleri iletmenize olanak tanır. Tüm bu bileşenler, her şeyin aynı şekilde optimize edildiğinden emin olmak için OPTIMIZE aracılığıyla optimizasyon ayarlarını alır. OPTIMIZE değişkeni için birkaç olası değer vardır:

  • -O0: Hiçbir optimizasyon yapmaz. Kullanılmayan kod kaldırılmaz ve Emscripten, oluşturduğu JavaScript kodunu küçültmez. Hata ayıklama için iyidir.
  • -O3: Performans için agresif bir şekilde optimizasyon yapın.
  • -Os: Performans ve ikincil ölçüt olarak boyut için agresif bir şekilde optimize edin.
  • -Oz: Gerekirse performanstan ödün vererek boyut için agresif bir şekilde optimizasyon yapın.

Web için çoğunlukla -Os'ü öneririm.

emcc komutunun kendi içinde birçok seçeneği vardır. emcc'nin "GCC veya clang gibi derleyiciler için doğrudan kullanılabilen bir değişim aracı" olması gerektiğini unutmayın. Bu nedenle, GCC'den bildiğiniz tüm işaretler büyük olasılıkla emcc tarafından da uygulanır. -s işareti, Emscripten'i özel olarak yapılandırmamıza olanak tanıması açısından özeldir. Kullanılabilir tüm seçenekleri Emscripten'in settings.js dosyasında bulabilirsiniz ancak bu dosya oldukça karmaşık olabilir. Web geliştiriciler için en önemli olduğunu düşündüğüm Emscripten işaretçilerinin listesini aşağıda bulabilirsiniz:

  • --bind, embind'i etkinleştirir.
  • -s STRICT=1, desteği sonlandırılan tüm derleme seçenekleri için desteği sonlandırıyor. Bu sayede kodunuz ileriye dönük uyumlu bir şekilde derlenir.
  • -s ALLOW_MEMORY_GROWTH=1, gerekirse belleğin otomatik olarak büyütülmesine olanak tanır. Bu makalenin yazıldığı sırada Emscripten başlangıçta 16 MB bellek ayırır. Kodunuz bellek parçaları ayırırken bu işlemlerin, bellek tükendiğinde wasm modülünün tamamının başarısız olmasına yol açıp açmayacağına veya yapıştırıcı kodunun, ayırmayı karşılamak için toplam belleği genişletmesine izin verilip verilmeyeceğine bu seçenek karar verir.
  • -s MALLOC=..., hangi malloc() uygulamasını kullanacağını seçer. emmalloc, özellikle Emscripten için küçük ve hızlı bir malloc() uygulamasıdır. Alternatif, tam teşekküllü bir malloc() uygulaması olan dlmalloc'tir. Yalnızca sık sık çok sayıda küçük nesne atıyorsanız veya mesaj dizileri kullanmak istiyorsanız dlmalloc'e geçmeniz gerekir.
  • -s EXPORT_ES6=1, JavaScript kodunu herhangi bir paketleyiciyle çalışan varsayılan bir dışa aktarma yöntemine sahip bir ES6 modülüne dönüştürür. Ayrıca -s MODULARIZE=1 değerinin ayarlanması gerekir.

Aşağıdaki işaretçiler her zaman gerekli değildir veya yalnızca hata ayıklama amacıyla faydalıdır:

  • -s FILESYSTEM=0, Emscripten ile ilgili bir işarettir ve C/C++ kodunuz dosya sistemi işlemleri kullandığında sizin için bir dosya sistemini taklit etme özelliğidir. Dosya sistemi emülasyonunun yapıştırıcı koda dahil edilip edilmeyeceğine karar vermek için derlediği kod üzerinde bazı analizler yapar. Ancak bazen bu analiz yanlış sonuç verebilir ve ihtiyacınız olmayabilecek bir dosya sistemi emülasyonu için 70 KB'lık oldukça büyük bir ek yapıştırıcı kod ödemeniz gerekebilir. -s FILESYSTEM=0 ile Emscripten'i bu kodu dahil etmemeye zorlayabilirsiniz.
  • -g4, Emscripten'in .wasm içine hata ayıklama bilgilerini eklemesini sağlar ve ayrıca wasm modülü için bir kaynak haritalar dosyası oluşturur. Emscripten ile hata ayıklama hakkında daha fazla bilgiyi hata ayıklama bölümünde bulabilirsiniz.

İşlem tamam! Bu kurulumu test etmek için küçük bir my-module.cpp oluşturalım:

    #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);
    }

Ve bir 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>

(Tüm dosyaları içeren bir gist buradadır.)

Her şeyi derlemek için

$ npm install
$ npm run build
$ npm run serve

localhost:8080 adresine gittiğinizde DevTools konsolunda aşağıdaki çıkışı görürsünüz:

C++ ve Emscripten üzerinden yazdırılan bir mesajı gösteren Geliştirici Araçları.

Bağımlılık olarak C/C++ kodu ekleme

Web uygulamanız için C/C++ kitaplığı oluşturmak istiyorsanız bu kitaplığın kodunun projenizin bir parçası olması gerekir. Kodu projenizin deposuna manuel olarak ekleyebilir veya bu tür bağımlılıkları yönetmek için npm'yi de kullanabilirsiniz. Web uygulamamda libvpx kullanmak istediğimi varsayalım. libvpx, .webm dosyalarında kullanılan codec olan VP8 ile görüntüleri kodlayan bir C++ kitaplığıdır. Ancak libvpx, npm'de bulunmaz ve package.json içermez. Bu nedenle, doğrudan npm'yi kullanarak yükleyemiyorum.

Bu çıkmazdan çıkmak için napa vardır. napa, herhangi bir git deposu URL'sini node_modules klasörünüze bağımlılık olarak yüklemenize olanak tanır.

napa'yı bağımlılık olarak yükleyin:

$ npm install --save napa

ve napa dosyasını yükleme komut dosyası olarak çalıştırdığınızdan emin olun:

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

npm install'ü çalıştırdığınızda napa, libvpx GitHub deposunu libvpx adı altında node_modules'inize klonlar.

Artık derleme komut dosyanızı libvpx'i oluşturacak şekilde genişletebilirsiniz. libvpx, derlenmek için configure ve make'yi kullanır. Neyse ki Emscripten, configure ve make'in Emscripten derleyicisini kullanmasını sağlayabilir. Bu amaçla emconfigure ve emmake sarmalayıcı komutları vardır:

# ... 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 ...

C/C++ kitaplıkları iki bölüme ayrılır: kitaplığın kullanıma sunduğu veri yapılarını, sınıfları, sabitleri vb. tanımlayan üstbilgiler (geleneksel olarak .h veya .hpp dosyaları) ve asıl kitaplık (geleneksel olarak .so veya .a dosyaları). Kitaplığın VPX_CODEC_ABI_VERSION sabit değerini kodunuzda kullanmak için #include ifadesi kullanarak kitaplığın üstbilgi dosyalarını eklemeniz gerekir:

#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;
}

Sorun, derleyicinin vpxenc.h değerini nerede arayacağını bilmemesinden kaynaklanıyor. -I işareti bunun içindir. Derleyiciye, başlık dosyaları için hangi dizinlerin kontrol edileceğini bildirir. Ayrıca derleyiciye gerçek kitaplık dosyasını da vermeniz gerekir:

# ... 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 ...

npm run build dosyasını şimdi çalıştırırsanız sürecin yeni bir .js ve yeni bir .wasm dosyası oluşturduğunu ve demo sayfasının gerçekten de sabit değeri döndürdüğünü görürsünüz:

emscripten aracılığıyla yazdırılan libvpx ABI sürümünü gösteren DevTools.

Ayrıca derleme sürecinin uzun sürdüğünü fark edeceksiniz. Derleme sürelerinin uzun olmasının nedeni değişebilir. libvpx söz konusu olduğunda, kaynak dosyalar değişmemiş olsa bile derleme komutunuzu her çalıştırdığınızda hem VP8 hem de VP9 için bir kodlayıcı ve kod çözücü derlendiğinden bu işlem uzun sürer. my-module.cpp'ünüzde yapılan küçük bir değişikliğin bile derlenmesi uzun zaman alır. libvpx'in derleme yapılarını ilk kez derlendikten sonra saklamak çok faydalı olacaktır.

Bunu yapmanın bir yolu, ortam değişkenlerini kullanmaktır.

# ... 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 ...

(Tüm dosyaları içeren bir özet buradadır.)

eval komutu, derleme komut dosyasına parametreler ileterek ortam değişkenlerini ayarlamamıza olanak tanır. $SKIP_LIBVPX ayarlanmışsa (herhangi bir değere) test komutu libvpx'i oluşturmayı atlar.

Artık modülünüzü derleyebilirsiniz ancak libvpx'i yeniden oluşturma adımını atlayabilirsiniz:

$ npm run build:emscripten -- SKIP_LIBVPX=1

Derleme ortamını özelleştirme

Bazen kitaplıklar oluşturmak için ek araçlara ihtiyaç duyulur. Bu bağımlılıklar, Docker görüntüsü tarafından sağlanan derleme ortamında yoksa bunları kendiniz eklemeniz gerekir. Örneğin, doxygen'i kullanarak libvpx dokümantasyonunu da oluşturmak istediğinizi varsayalım. Doxygen, Docker kapsayıcınızda kullanılamaz ancak apt kullanarak yükleyebilirsiniz.

build.sh'de bunu yaparsanız kitaplığınızı oluşturmak istediğinizde her seferinde doxygen'u yeniden indirip yüklemeniz gerekir. Bu durum, yalnızca gereksiz harcama yapmanıza değil, internete bağlı değilken projenizde çalışmanıza da engel olur.

Bu durumda kendi Docker görüntünüzü oluşturmanız mantıklı olacaktır. Docker görüntüleri, oluşturma adımlarını açıklayan bir Dockerfile yazılarak oluşturulur. Dockerfile'ler oldukça güçlüdür ve çok sayıda komut içerir ancak çoğu zaman yalnızca FROM, RUN ve ADD komutlarını kullanarak işinizi halledebilirsiniz. Bu durumda:

FROM trzeci/emscripten

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

FROM ile başlangıç noktası olarak kullanmak istediğiniz Docker görüntüsünü belirtebilirsiniz. Temel olarak trzeci/emscripten'yi (tüm bu süre boyunca kullandığınız resim) seçtim. RUN ile Docker'a, container'ın içinde kabuk komutları çalıştırmasını talimat verirsiniz. Bu komutların kapsayıcıda yaptığı değişiklikler artık Docker görüntüsünün bir parçasıdır. build.sh'ü çalıştırmadan önce Docker görüntünüzün oluşturulduğundan ve kullanılabilir olduğundan emin olmak için package.json'unuzu biraz ayarlamanız gerekir:

{
    // ...
    "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",
    // ...
    },
    // ...
}

(Tüm dosyaları içeren bir özet buradadır.)

Bu işlem, Docker görüntünüzü oluşturur ancak yalnızca henüz oluşturulmamışsa. Ardından her şey eskisi gibi çalışır ancak artık derleme ortamında doxygen komutu mevcuttur. Bu komut, libvpx dokümanının da derlenmesine neden olur.

Sonuç

C/C++ kodunun ve npm'nin doğal bir uyum içinde olmaması şaşırtıcı değildir ancak bazı ek araçlar ve Docker'ın sağladığı izolasyonla bu ikisini oldukça rahat bir şekilde çalıştırabilirsiniz. Bu kurulum her proje için uygun olmayabilir ancak ihtiyaçlarınıza göre ayarlayabileceğiniz iyi bir başlangıç noktasıdır. İyileştirme önerileriniz varsa lütfen paylaşın.

Ek: Docker görüntü katmanlarını kullanma

Alternatif bir çözüm, Docker ve Docker'ın önbelleğe alma konusundaki akıllı yaklaşımıyla bu sorunların daha fazlasını kapsamaktır. Docker, Dockerfile'leri adım adım yürütür ve her bir adımın sonucuna kendi görüntüsünü atar. Bu ara görüntülere genellikle "katmanlar" denir. Dockerfile'deki bir komut değişmediyse Docker, Dockerfile'i yeniden derlediğiniz sırada bu adımı yeniden çalıştırmaz. Bunun yerine, resmin son oluşturulduğu andaki katmanı yeniden kullanır.

Daha önce, uygulamanızı her oluşturduğunuzda libvpx'i yeniden oluşturmamak için biraz çaba sarf etmeniz gerekiyordu. Bunun yerine, Docker'ın önbelleğe alma mekanizmasından yararlanmak için libvpx'in derleme talimatlarını build.sh'ünüzden Dockerfile'e taşıyabilirsiniz:

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

(Tüm dosyaları içeren bir özet buradadır.)

docker build'ü çalıştırırken bind bağlama işlemi yapmadığınız için git'i manuel olarak yüklemeniz ve libvpx'i klonlamanız gerektiğini unutmayın. Bu nedenle artık napa'ya gerek yoktur.