某项目要集成 PDF 文件的 OCR 功能,不过由于此功能技术难度太大,网络上找不到靠谱的开源实现,最终不得不选择 ABBYY FineReader Engine 的付费服务。可惜 ABBYY 只提供了 C++ 和 Java 两种编程语言的 SDK,而我们的项目采用的编程语言是 Golang,此时通常的集成方法是使用 C++ 或 Java 实现一个服务,然后在 Golang 项目里通过 RPC 调用服务,不过如此一来明显增加了系统的复杂度,好在 Golang 支持 CGO,让我们可以很方便的在 Golang 中使用 C 模块,本文总结了我在学习 CGO 过程中的心得体会。
让我们看看一个 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 *。
有些读者看到这里可能会有疑问:虽然 CGO 让我们可以在 Golang 里使用 C,但是文章开头提到的 ABBYY 并没有 C 的 SDK,只有 C++ 的 SDK,那么 CGO 支持 C++ 么?答案是否定的,不过我们可以通过 C 来适配 C++。
以 ABBYY 为例,假设它的安装目录是 /opt/ABBYY/FREngine12,并且通过 ldconfig 把 /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<IFRDocument> frDocument = 0;
CSafePtr<IDocumentProcessingParams> documentProcessingParams;
CSafePtr<IPageProcessingParams> pageProcessingParams;
CSafePtr<IRecognizerParams> 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”」,有了它,当编译的时候,就会把 C++ 中的方法名链接成 C 的风格,如此一来,CGO 才能识别它:
#ifdef __cplusplus
extern "C" {
#endif
void load();
void unload();
void process(const char *inPath, const char *outPath);
#ifdef __cplusplus
}
#endif
我们可以通过 nm 命令查看某个方法名在使用 extern “C” 前后的差异:
// Before
shell> nm OCR.o | grep process
0000000000000016 T _Z7processPKcS0_
// After
shell> nm OCR.o | grep process
0000000000000016 T process
最后编写 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:
shell> gcc -shared -o libOCR.so *.o
shell> cp libOCR.so /opt/ABBYY/FREngine12/Bin/
动态链接库还有一个优点是更新方便,如果多个程序依赖同一个动态链接库的时候,那么当动态链接库有问题的时候,直接更新它即可,相反如果多个程序依赖同一个静态链接库,那么当静态链接库有问题的时候,你不得不重新编译每一个程序。不过动态链接库的依赖关系本身很容易出问题,下图是我的 OCR 程序依赖关系,有点复杂啊:
动态链接
本文仅是 CGO 的入门笔记,想进一步了解的话,推荐阅读「CGO 编程」,收摊儿。