實戰 CGO

某項目要集成 PDF 文件的 OCR 功能,不過由於此功能技術難度太大,網絡上找不到靠譜的開源實現,最終不得不選擇 ABBYY FineReader Engine[1] 的付費服務。可惜 ABBYY 只提供了 C++ 和 Java 兩種編程語言的 SDK,而我們的項目採用的編程語言是 Golang,此時通常的集成方法是使用 C++ 或 Java 實現一個服務,然後在 Golang 項目裏通過 RPC 調用服務,不過如此一來明顯增加了系統的複雜度,好在 Golang 支持 CGO,讓我們可以很方便的在 Golang 中使用 C 模塊,本文總結了我在學習 CGO 過程中的心得體會。

Hello World

讓我們看看一個 CGO 版本的 Hello, world 大概長什麼樣:

package main

/*
#include <stdio.h>

void say(const char *s) {
    puts(s);
}
*/
import "C"

func main() {
    hello()
}

func hello() {
    s := C.CString("Hello, World\n")
    C.say(s)
}

如上所示,通過「import “C”」來激活 CGO,並且所有 C 語言相關的代碼都以註釋的形式放在此行之上,中間不允許有空行,這樣我們就可以在 Golang 代碼裏使用 C 模塊了,看上去很簡單,不過代碼裏存在內存泄漏,讓我們修改一下代碼,使問題更明顯一點:

package main

/*
#include <stdio.h>

void say(const char *s) {
    puts(s);
}
*/
import "C"

func main() {
    for {
        hello()
    }
}

func hello() {
    s := C.CString("Hello, World\n")
    C.say(s)
}

運行程序後,我們可以單獨開一個命令行窗口,通過運行 top 命令來監控進程的內存變化,會發現在循環調用 C 模塊之後,進程的內存佔用不斷增加,究其原因,是因爲通過 C.CString 創建的變量,會在 C 語言層面上分配內存,而在 Golang 語言層面上是不會負責管理相關內存的,所以我們需要通過 C.free 手動釋放相關內存:

package main

/*
#include <stdio.h>
#include <stdlib.h>

void say(const char *s) {
    puts(s);
}
*/
import "C"
import "unsafe"

func main() {
    for {
        hello()
    }
}

func hello() {
    s := C.CString("Hello, World\n")
    defer C.free(unsafe.Pointer(s))
    C.say(s)
}

說明:代碼中的 unsafe.Pointer 相當於 C 語言中的 void *。

In Action

有些讀者看到這裏可能會有疑問:雖然 CGO 讓我們可以在 Golang 裏使用 C,但是文章開頭提到的 ABBYY 並沒有 C 的 SDK,只有 C++ 的 SDK,那麼 CGO 支持 C++ 麼?答案是否定的,不過我們可以通過 C 來適配 C++。

以 ABBYY 爲例,假設它的安裝目錄是 /opt/ABBYY/FREngine12,並且通過 ldconfig[2] 把 /opt/ABBYY/FREngine12/Bin 目錄加入到動態鏈接庫的查找目錄:

shell> echo "/opt/ABBYY/FREngine12/Bin" > /etc/ld.so.conf.d/abbyy.conf
shell> ldconfig

準備工作做好後使用 /opt/ABBYY/FREngine12/Samples/Hello 例子做代碼範本:

先編寫 OCR.cpp 文件的內容,不用在意技術細節,我放這些代碼只是爲了備份:

#include <string>
#include "AbbyyException.h"
#include "BstrWrap.h"
#include "FREngineLoader.h"
#include "./OCR.h"

using namespace std;

void load() {
    LoadFREngine();
}

void unload() {
    UnloadFREngine();
}

void process(const char *inPath, const char *outPath) {
    string file = outPath;
    string extension = file.substr(file.find_last_of(".") + 1);
    FileExportFormatEnum format;

    if (extension == "pdf") {
        format = FEF_PDF;
    } else if (extension == "doc" || extension == "docx") {
        format = FEF_DOCX;
    } else if (extension == "ppt" || extension == "pptx") {
        format = FEF_PPTX;
    } else if (extension == "xls" || extension == "xlsx") {
        format = FEF_XLSX;
    } else {
        return;
    }

    const wchar_t *language = L"ChinesePRC,ChineseTaiwan,English";
    CSafePtr frDocument = 0;
    CSafePtr documentProcessingParams;
    CSafePtr pageProcessingParams;
    CSafePtr recognizerParams;

    try {
        CheckResult(FREngine->CreateFRDocumentFromImage(CBstr(inPath), 0, &frDocument));
        CheckResult(FREngine->CreateDocumentProcessingParams(&documentProcessingParams));
        CheckResult(documentProcessingParams->get_PageProcessingParams(&pageProcessingParams));
        CheckResult(pageProcessingParams->get_RecognizerParams(&recognizerParams));
        CheckResult(recognizerParams->SetPredefinedTextLanguage(CBstr(language)));
        CheckResult(frDocument->Process(documentProcessingParams));
        CheckResult(frDocument->Export(CBstr(outPath), format, 0));
    } catch (...) {
        return;
    }
}

再編寫 OCR.h 文件的內容,要特別注意其中的「extern “C”[3]」,有了它,當編譯的時候,就會把 C++ 中的方法名鏈接成 C 的風格,如此一來,CGO 才能識別它:

#ifdef __cplusplus
extern "C" {
#endif
void load();
void unload();
void process(const char *inPath, const char *outPath);
#ifdef __cplusplus
}
#endif

最後編寫 OCR.go 文件的內容,因爲 C/C++ 代碼量比較大,所以在使用 CGO 的時候直接把 C/C++ 代碼寫在註釋中就顯得不合適了,此時更合適的方法是鏈接庫:

package main

// #cgo CFLAGS: -I .
// #cgo LDFLAGS: -L . -L /opt/ABBYY/FREngine12/Bin/ -lFREngine -lOCR -lstdc++
// #include <stdlib.h>
// #include "OCR.h"
import "C"
import (
 "flag"
 "os"
 "unsafe"
)

func main() {
 flag.Parse()

 if flag.NArg() != 2 {
  os.Exit(1)
 }

 C.load()
 inPath := C.CString(flag.Arg(0))
 outPath := C.CString(flag.Arg(1))

 defer func() {
  C.unload()
  C.free(unsafe.Pointer(inPath))
  C.free(unsafe.Pointer(outPath))
 }()

 C.process(inPath, outPath)
}

假設目標文件都已經就緒,那麼讓我們分別看看如何構建靜態鏈接庫和動態鏈接庫:

先看靜態鏈接庫,只要通過如下 ar 命令即可,在最終編譯程序的時候,靜態鏈接庫會被編譯到程序裏,所以運行時不存在依賴問題,當然代價就是文件尺寸相對較大:

shell> ar -r libOCR.a *.o

再看動態鏈接庫,只要通過如下 gcc 命令即可,和靜態鏈接庫相比,雖然它運行時存在依賴問題,但是它生成的文件尺寸相對較小,不過需要提醒的是,在之前編譯目標文件的時候,需要在 CFLAGS 或 CXXFLAGS 參數中需要加入 -fpic 或者 -fPIC 選項,以便實現地址無關,至於 -fpic 和 -fPIC 的區別,可以參考 Shared Libraries[4]:

shell> gcc -shared -o libOCR.so *.o
shell> cp libOCR.so /opt/ABBYY/FREngine12/Bin/

動態鏈接庫還有一個有點是更新方便,如果多個程序依賴同一個動態鏈接庫的時候,那麼當動態鏈接庫有問題的時候,直接更新它即可,相反如果多個程序依賴同一個靜態鏈接庫,那麼當靜態鏈接庫有問題的時候,你不得不重新編譯每一個程序。不過動態鏈接庫的依賴關係本身很容易出問題,下圖是我的 OCR 程序依賴關係,有點複雜啊:

動態鏈接

本文僅是 CGO 的入門筆記,想進一步瞭解的話,推薦閱讀「CGO 編程 [5]」,收攤兒。

參考資料

[1] ABBYY FineReader Engine: https://www.abbyy.com/ocr-sdk/

[2] ldconfig: https://linux.die.net/man/8/ldconfig

[3] extern “C”: https://stackoverflow.com/questions/1041866/what-is-the-effect-of-extern-c-in-c

[4] Shared Libraries: https://tldp.org/HOWTO/Program-Library-HOWTO/shared-libraries.html

[5] CGO 編程: https://chai2010.cn/advanced-go-programming-book/ch2-cgo/readme.html

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/_gsClVKA7S1jGdF6wIB2vg