某项目要集成 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 编程」,收摊儿。