Node.js, Golang ve Electron.js Kullanarak Performanslı Masaüstü Uygulamalar Geliştirmek

nodejsgolang

1 yıl önce 0 yorum

Başlıkta ifade ettiğim gibi bazı durumlarda performanslı çalışabilecek uygulamalar üretmek isteriz. Bu tür programlar genelde derlenebilir dillerle yazılırlar. C++, Go, C# vs. gibi. C++ şu anda aralarında en performanslı çalışabilecek dil olduğu gibi aynı zamanda öğrenmesi ve geliştirmesi de bir o kadar zor bir dil. C# ise .Net bağımlı bir dil. Ancak kolay dağıtılabilir ve yazılabilir dil aradığımızda Go bu diller arasında öne çıkıyor.

Saf Go ile yazılmış çok kullanışlı kütüphaneler Go yazmak için büyük bir sebep. Peki biz nasıl olur da Go ile yazdığımız bir kodu Node.js’e entegre edebiliriz. Bunun direkt olarak birkaç yöntemi mevcut. Bunlar:

  1. child_process kullanarak derlenmiş go kodunu çalıştırmak
  2. Go ile yazılmış bir api
  3. Node.js’e bir addon (eklenti) yazarak

İlk seçenek aralarında kolay görünüyor ancak şunu unutmamak gerek uygulamanızı dağıttığınızda executable olarak 2 dosya çıkmanız gerekiyor. Birincisi sizin uygulamanız diğeri Go ile derlenen kod. İkisini de imzalamanız gerekiyor. Ve çoğu durumda Go kodunuz bir virüs olarak görülebilir. Kısaca imzalama maliyetine değmeyecek bir yöntem.

İkinci seçenek de bir sunucu maliyeti gerektiriyor. Ve bu tam olarak bu makaleyi karşılamıyor. Çünkü bu makalede offline bir şekilde masaüstü uygulamasında go kodu çalıştırmaktan bahsedeceğim.

Üçüncü seçenek yani bu makalenin konusu:

Node.js C++ Golang Bridge

Node.js’e eklentiler yazılabilir. Ancak Node.js’in (V8) alt yapısı gereği ona eklenti yazabilmek için C++ kullanmak gerekir. C++ ise bizim istemediğimiz bir dildi. Ama biz burada C++’ı bir köprü olarak kullanacağız.

C++ ile Go’yu birbirine nasıl bağlarız? Go’yu c-shared veya c-archive modunda derlersek çıktı olarak closed source bir lib ve open source bir head dosyası alırız. Bu Windows’ta .DLL, Linux/Unix’de .SO veya .A uzantılarında çıkacak bir dosya.

Sonra dosyayı C++’a bir lib olarak belirtirsek ve .h dosyasını include edersek artık C++’da Go kodunu çalıştırabilir oluruz. Köprü kodumuz yazıldığında derleyebilmemiz için node-gyp kullanacağız. Ardından derlenmiş koda erişmek JS için çok basit olacak. Ör: 

const addon = require('./build/Release/addon')

Hadi bu işi yapalım. Öncelikle CPU kullanımı yüksek bir Go projesi oluşturalım:

$ mkdir awesome-lib
$ cd awesome-lib
$ go mod init example/awesome-lib
$ touch awesome-lib.go
package main

import (
	"fmt"
	"time"
)

func AwesomeJob(second int) {
	startTime := time.Now()

	fmt.Println("AwesomeJob started")

	// High CPU usage
	time.Sleep(time.Duration(second) * time.Second)

	endTime := time.Now()
	fmt.Println("AwesomeJob ended! ", endTime.Sub(startTime))
}

func main() {
	AwesomeJob(5)
}

Bu Go kodunu go run . ile çalıştırdığımızda aşağıdaki gibi bir sonuç elde ederiz:

$ go run awesome-lib.go
AwesomeJob started
AwesomeJob ended! 5.002612034s
$ 

Şimdi bu kodu c-shared olarak derleyelim, ama öncesinde AwesomeJob fonksiyonunu .h dosyasında belirtmek için bir yorum eklemeliyiz. Aynı zamanda “C” kütüphanesini de import etmeliyiz:

...

import (
	"C"
	...
)

//export AwesomeJob
func AwesomeJob(second int) {
...
$ go build -o awesome-lib.so -buildmode=c-shared awesome-lib.go

Ve derlediğimizde awesome-lib.sove awesome-lib.h dosyalarını görürüz. Artık C++ bu kütüphaneyi kullanabilir hale geldi.

Node.js’de addon yazmak için Node-API kullanmak en sağlıklı yöntem gibi görünüyor. node_api kullanmak için gerekli olan npm paketini de kuralım. Aynı zamanda projemizin ana dizinine addon.cc adında bir dosya oluşturalım ve köprüyü oluşturmak için gerekli olan tüm kodu içerisine yazalım.

$ yarn add node-addon-api
#include <chrono>
#include <thread>
#include <assert.h>
#include "napi.h"

#include "awesome-lib.h"

struct awesome_options
{
    GoInt64 second;
};

struct AwesomeContext
{
    AwesomeContext(Napi::Env env) : deferred(Napi::Promise::Deferred::New(env)){};

    // Native Promise returned to JavaScript
    Napi::Promise::Deferred deferred;

    // Native thread
    std::thread nativeThread;

    // options
    awesome_options *opts;

    int output;

    Napi::ThreadSafeFunction tsfn;
};

// The thread entry point. This takes as its arguments the specific
// threadsafe-function context created inside the main thread.
void awesomeJobThreadEntry(AwesomeContext *context)
{
    awesome_options *opts = context->opts;

    AwesomeJob(opts->second);

    context->output = 1;

    auto callback = [](Napi::Env env, Napi::Function jsCallback, int *data)
    {
        jsCallback.Call({Napi::Number::New(env, *data)});
    };

    napi_status status =
        context->tsfn.BlockingCall(&context->output, callback);

    if (status != napi_ok)
    {
        Napi::Error::Fatal(
            "ThreadEntry",
            "Napi::ThreadSafeNapi::Function.BlockingCall() failed");
    }

    // Release the thread-safe function. This decrements the internal thread
    // count, and will perform finalization since the count will reach 0.
    context->tsfn.Release();
}

void FinalizerCallback(Napi::Env env,
                       void *finalizeData,
                       AwesomeContext *context)
{
    // Join the thread
    context->nativeThread.join();

    // Resolve the Promise previously returned to JS via the AwesomeJob method.
    context->deferred.Resolve(Napi::Number::New(env, context->output));
    delete context;
}

// Exported JavaScript function. Creates the thread-safe function and native
// thread. Promise is resolved in the thread-safe function's finalizer.
Napi::Value AwesomeJobBridge(const Napi::CallbackInfo &info)
{
    assert(info[0].IsNumber());
    assert(info[1].IsFunction());

    Napi::Env env = info.Env();

    // Construct context data
    auto awesomeData = new AwesomeContext(env);

    double second = info[0].As<Napi::Number>().DoubleValue();
    Napi::Function callback = info[1].As<Napi::Function>();

    awesome_options *opts = new awesome_options;
    opts->second = second;

    awesomeData->opts = opts;

    // Create a new ThreadSafeFunction.
    awesomeData->tsfn = Napi::ThreadSafeFunction::New(
        env,               // Environment
        callback,          // JS function from caller
        "AWESOME_JOB",     // Resource name
        0,                 // Max queue size (0 = unlimited).
        1,                 // Initial thread count
        awesomeData,       // Context,
        FinalizerCallback, // Finalizer
        (void *)nullptr    // Finalizer data
    );

    awesomeData->nativeThread = std::thread(awesomeJobThreadEntry, awesomeData);

    // Return the deferred's Promise. This Promise is resolved in the thread-safe
    // function's finalizer callback.
    return awesomeData->deferred.Promise();
}

// Addon entry point
Napi::Object Init(Napi::Env env, Napi::Object exports)
{
    exports["awesomeJob"] = Napi::Function::New(env, AwesomeJobBridge);
    return exports;
}

NODE_API_MODULE(addon, Init)

Yukarıda bahsettiğim örnek Node-API'ni Thread-safe functions örneği değiştirilerek hazırlanmıştır. Burada thread safe olayından bahsetmek gerek. Eğer biz direkt olarak bir thread çalıştırsaydık ve JS'e callback'i dönmeye çalışsaydık bu mümkün değildi. Örneğin asenkron bir addon yazmak için uv_queue_work kullanılabilir ama o fonksiyonla JS oturumunu kaybederiz. Bu problemi ancak bir event loop mantığında yapmamız gerekir. İşte bu yüzden node_api bize thread safe api'larını sunduğu için işimiz daha kolay oluyor.

Addon hazır olduğuna göre tüm derleme ayarlarını binding.gyp dosyasına kaydedelim.

{
    "targets": [
        {
            "target_name": "awesome-lib",
            'defines': ['V8_DEPRECATION_WARNINGS=1'],
            "sources": [
                "awesome-lib.h",
                "addon.cc"
            ],
            "libraries": ["../awesome-lib.so"],
            'include_dirs': ["<!@(node -p \"require('node-addon-api').include\")"],
            'dependencies': ["<!(node -p \"require('node-addon-api').gyp\")"],
            'cflags!': ['-fno-exceptions'],
            'cflags_cc!': ['-fno-exceptions'],
            'xcode_settings': {
                'GCC_ENABLE_CPP_EXCEPTIONS': 'YES',
                'CLANG_CXX_LIBRARY': 'libc++',
                'MACOSX_DEPLOYMENT_TARGET': '10.7',
            },
            'msvs_settings': {
                'VCCLCompilerTool': {
                    'ExceptionHandling': 1
                },
            },
        }
    ]
}

Hadi derleyelim de çalıştıralım şu kodu. Aşağıdaki komutla derlersek build/Release/awesome-lib.node adındaki dosyamız çıkmış olacak:

$ node-gyp rebuild
...
gyp info spawn args [ 'BUILDTYPE=Release', '-C', 'build' ]
  CC(target) Release/obj.target/nothing/node_modules/node-addon-api/nothing.o
  LIBTOOL-STATIC Release/nothing.a
warning: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/libtool: archive library: Release/nothing.a the table of contents is empty (no object file members in the library define global symbols)
  CXX(target) Release/obj.target/awesome-lib/addon.o
  SOLINK_MODULE(target) Release/awesome-lib.node
ld: warning: dylib (../awesome-lib.so) was built for newer macOS version (11.0) than being linked (10.7)
gyp info ok

Bu derlemeye ek olarak projenin çalışacağı işlemci mimarisine göre derleme almak için --arch parametresini verebiliriz. Örneğin M1 yani arm64 işlemciler için çalışmasını istiyorsak node-gyp rebuild --arch=arm64 yapabiliriz. (Not: Node.js'in de arm64 olarak kurulmuş olması gerekir)

Evet ilk başta bahsettiğimiz gibi sıra geldi nodejs'de bu eklentimizi kullanmaya. Bunu yapmak için proje dizininde index.js diye bir dosya oluşturalım:  

const AwesomeLib = require('./build/Release/awesome-lib');

AwesomeLib.awesomeJob(4, (res) => {
    console.log("iş bitti sonuç:", res);
});

Bu kodu node index.js komutunu kullanarak çalıştırdığımızda çıkan sonuç aşağıdaki gibi olacaktır:

$ node index.js
AwesomeJob started
AwesomeJob ended!  4.001123458s
iş bitti sonuç: 1

Ve tamamdır artık JavaScript projelerinde go kullanabiliriz. Ancak burada ilgi çeken bir şeyler var; biz JavaScript kodunda kullanmasak bile yazdığımız go kodundaki print komutlarının buraya çıktı olarak geldiğini görüyoruz.

İşte zurnanın zırt dediği yer...

Bu makeleyi hazırlamamdaki aslı sebep print çıktılarına JS kısmından erişerek bir fonksiyon yardımıyla dinleyebilmekti. Peki buna ihtiyacım neydi? Şöyle düşünelim JS'de iki tür return yapısı var, aslında birisi callback diğeri promise; callback aralarında scopu kaybetmeden birden fazla değer return edebilen bir yapı, ama promise sadece bir kere response verebiliyor. Tamam peki biz callback kullanarak bu işi çözelim ama callback'i kim çalıştıracak? tabii ki go kodumuz. Bunları yapmak için gidip go metodumuzda bir callback mekanizması kurmamız gerekecek.

Her şeyi bir kenara bırakıp callback vs kullanmadan bu sorunu çözmeye çalıştım. Aklıma ilk olarak process.stdout geldi. Bu stream sunan metod ile aslında print çıktılarını vs yakayalabiliyorsunuz. Ancak konu Node.js eklentilerine gelince C++ içerisinde yazılmış olan print türevi şeylerden JS scope'u haberdar olmuyor. Çünkü bunu yapmak için JS scope'una bu durumları haberdar eden bir thread açmak gerekecektir. Ve ben bu teknolojiyi araştırmak için kendimde efor bulamadım. Çünkü callback yapmak en iyi ve sağlam çözümdür.

Gelelim go modülümüzden C++'a callback yapabilme teknolojisine :) Öncelikle go koduna eklediğimiz "C" importunu yukarı alıp üzerine birkaç yorum ekleyelim. O eklediğimiz yorumların içerisinde aslında çıkacak olan .h dosyasına birkaç fonksiyon ekliyoruz. Sonra da go kodumuza callback'i yerleştirelim. Sonuç olarak tüm awesome-lib.go dosyamız şöyle olacaktır:

package main

/*
#ifndef ADDON_H_
#define ADDON_H_
typedef void (*gocallback)(const char *log, void *ptr);
#endif

static void runFunc(gocallback f, const char *msg, void *ptr) {
	f(msg, ptr);
}
*/
import "C"

import (
	"fmt"
	"time"
	"unsafe"
	"strings"
)

type Callback func(msg string)

//export AwesomeJob
func AwesomeJob(second int, cb unsafe.Pointer, cbData unsafe.Pointer) {
	var callback Callback
	if cb == nil {
		callback = func(msg string) {
			fmt.Print(msg)
		}
	} else {
		callback = func(msg string) {
			f := C.gocallback(cb)
			C.runFunc(f, C.CString(strings.TrimSpace(msg)), cbData)
		}
	}

	startTime := time.Now()

	callback(fmt.Sprintf("AwesomeJob started"))

	// High CPU usage
	time.Sleep(time.Duration(second) * time.Second)

	endTime := time.Now()
	callback(fmt.Sprintf("AwesomeJob ended! %s", endTime.Sub(startTime)))
}

func main() {
	AwesomeJob(5, nil, nil)
}

Göründüğü üzere iki adet parametre ekledik birincisi fonksiyonun kendisi, diğeri o fonksiyona gönderilecek bağımsız başka bir değer. Peki neden ikinci değere ihtiyaç duyduk? sadece fonksiyon yetmiyor muydu? O kısmı da addon.cc dosyasında  daha iyi anlayacağız. Şimdi addon.cc dosyasında birkaç değişiklik yapalım. Bunlardan en önemlisi callback yapacağımız yerde yani awesomeJobThreadEntry metodunda. Hatırlarsak bu metoda context adında bir pointer geliyordu. İşte biraz önce bahsettiğimiz ikinci parametre bu context'dir. Şöyle düşünebiliriz, biz aslında bu callback'i birden fazla çalıştıracağız değil mi? yani addon.cc'de bulunan c->tsfn.BlockingCall kodu birden fazla çalışacak. Ancak bizim callback'imizi bağımsız bir yerde olacağı için bu context'i global bir yerde tutmak doğru olmaz. Eğer öyle yaparsak fonksiyonun ikinci çağrılışında yanlış scope'a haber vermiş oluruz. O yüzden callback metodumuzun kendine has bir context'i olmalıdır. Sonuç olarak addon.cc dosyamızdaki değişiklik şu şekilde olacaktır:

...

// This callback transforms the native addon data (int *data) to JavaScript
// values. It also receives the treadsafe-function's registered callback, and
// may choose to call it.
void JSCallback(Napi::Env env, Napi::Function jsCallback, std::string log) {
    jsCallback.Call({Napi::String::New(env, log)});
}

void GoCallback(const char *msg, void *ptr)
{
    AwesomeContext *c = reinterpret_cast<AwesomeContext *>(ptr);

    // Perform a call into JavaScript.
    napi_status status =
        c->tsfn.BlockingCall(msg, JSCallback);

    if (status != napi_ok)
    {
        Napi::Error::Fatal(
            "ThreadEntry",
            "Napi::ThreadSafeNapi::Function.BlockingCall() failed");
    }
}

// The thread entry point. This takes as its arguments the specific
// threadsafe-function context created inside the main thread.
void awesomeJobThreadEntry(AwesomeContext *context)
{
    awesome_options *opts = context->opts;

    AwesomeJob(
        opts->second,
        (void*)GoCallback,
        (void*)context
    );

    context->output = 1;

    // Release the thread-safe function. This decrements the internal thread
    // count, and will perform finalization since the count will reach 0.
    context->tsfn.Release();
}

...

Bunu yaptıktan sonra index.js dosyamızıda şu şekilde değiştirelim:

const AwesomeLib = require('./build/Release/awesome-lib');

AwesomeLib.awesomeJob(4, (log) => {
    console.log(log);
})
.then(res => {
    console.log("iş bitti sonuç:", res);
});

Böylelikle aslında log fonksiyonumuz callback ve result değerimiz de promise olmuş oldu. Şimdi derlememizi yapıp tekrar JavaScript kodumuzu çalıştıralım ve sonucu görelim:

$ go build -o awesome-lib.so -buildmode=c-shared awesome-lib.go \
  && node-gyp rebuild
...

$ node index.js
AwesomeJob started
AwesomeJob ended! 4.001176709s
iş bitti sonuç: 1

Evet böylelikle hem go kodumuzdan bir çıktı alabiliyoruz hem de JS'i bloklamadan eklentimizi çalıştırabiliyoruz. Biraz C++ kodunu incelediğinizde aslında mantığını anlayacağınıza eminim. Bu projenin tüm kodlarını GitHub'da bulabilirsiniz: github.com/abdurrahmanekr/nodejs-golang-addon

Electron

Evet electron'da bu modülü kullanmak çok basit olacak, main.js ve index.html yukarıda belirttiğim GitHub reposunda mevcut. Tek yapılması gereken yarn electron:start böylelikle açılan electron uygulaması aşağıdaki gibi görünecektir:

Makalelerimi merak ediyorsanız bloguma abone olarak bana destek olabilirsiniz. Böylelikle makaleleri kaçırmamış olursunuz. Beni okuduğunuz için teşekkürler :) sağlıcakla kalın :)

Düşündüklerin nedir ?

Abdurrahman Eker

(1010 Eylül 11111001100)

  • Full Stack Developer Turkey/Sivas
  • İnternette Avare Kodcu
  • coffee
  • github
  • instagram
  • linkedin
  • youtube
  • Yeni içeriklerden haberdar olmak ister misin ?