掃二維碼與項目經(jīng)理溝通
我們在微信上24小時期待你的聲音
解答本文疑問/技術(shù)咨詢/運營咨詢/技術(shù)建議/互聯(lián)網(wǎng)交流
不同的開發(fā)語言適合不同的領(lǐng)域,例如Python適合做數(shù)據(jù)分析,C++適合做系統(tǒng)的底層開發(fā),假如它們需要用到相同功能的基礎組件,組件使用多種語言分別開發(fā)的話,不僅增加了開發(fā)和維護成本,而且不能確保多種語言間在處理效果上是一致的。本文以美團搜索實際場景下的案例,講述在Linux系統(tǒng)下跨語言調(diào)用的實踐,即開發(fā)一次C++語言的組件,其他語言通過跨語言調(diào)用技術(shù)調(diào)用C++組件。

1 背景介紹
2 方案概述
3 實現(xiàn)詳情
4. 原理介紹
5 應用案例
6 總結(jié)
查詢理解(QU, Query Understanding)是美團搜索的核心模塊,主要職責是理解用戶查詢,生成查詢意圖、成分、改寫等基礎信號,應用于搜索的召回、排序、展示等多個環(huán)節(jié),對搜索基礎體驗至關(guān)重要。該服務的線上主體程序基于C++語言開發(fā),服務中會加載大量的詞表數(shù)據(jù)、預估模型等,這些數(shù)據(jù)與模型的離線生產(chǎn)過程有很多文本解析能力需要與線上服務保持一致,從而保證效果層面的一致性,如文本歸一化、分詞等。
而這些離線生產(chǎn)過程通常用Python與Java實現(xiàn)。如果在線、離線用不同語言各自開發(fā)一份,則很難維持策略與效果上的統(tǒng)一。同時這些能力會有不斷的迭代,在這種動態(tài)場景下,不斷維護多語言版本的效果打平,給我們的日常迭代帶來了極大的成本。因此,我們嘗試通過跨語言調(diào)用動態(tài)鏈接庫的技術(shù)解決這個問題,即開發(fā)一次基于C++的so,通過不同語言的鏈接層封裝成不同語言的組件庫,并投入到對應的生產(chǎn)過程。這種方案的優(yōu)勢非常明顯,主體的業(yè)務邏輯只需要開發(fā)一次,封裝層只需要極少量的代碼,主體業(yè)務迭代升級,其它語言幾乎不需要改動,只需要包含最新的動態(tài)鏈接庫,發(fā)布最新版本即可。同時C++作為更底層的語言,在很多場景下,它的計算效率更高,硬件資源利用率更高,也為我們帶來了一些性能上的優(yōu)勢。
本文對我們在實際生產(chǎn)中嘗試這一技術(shù)方案時,遇到的問題與一些實踐經(jīng)驗做了完整的梳理,希望能為大家提供一些參考或幫助。
為了達到業(yè)務方開箱即用的目的,綜合考慮C++、Python、Java用戶的使用習慣,我們設計了如下的協(xié)作結(jié)構(gòu):
圖 1
Python、Java支持調(diào)用C接口,但不支持調(diào)用C++接口,因此對于C++語言實現(xiàn)的接口,必須轉(zhuǎn)換為C語言實現(xiàn)。為了不修改原始C++代碼,在C++接口上層用C語言進行一次封裝,這部分代碼通常被稱為“膠水代碼”(Glue Code)。具體方案如下圖所示:
圖 2
本章節(jié)各部分內(nèi)容如下:
作為示例,實現(xiàn)一個打印字符串的功能。為了模擬實際的工業(yè)場景,對以下代碼進行編譯,分別生成動態(tài)庫 libstr_print_cpp.so、靜態(tài)庫libstr_print_cpp.a。
str_print.h
#pragma once
#include
class StrPrint {
public:
void print(const std::string& text);
};
str_print.cpp
#include
#include "str_print.h"
void StrPrint::print(const std::string& text) {
std::cout << text << std::endl;
}
如上文所述,需要對C++庫進行封裝,改造成對外提供C語言格式的接口。
c_wrapper.cpp
#include "str_print.h"
extern "C" {
void str_print(const char* text) {
StrPrint cpp_ins;
std::str
ing str = text;
cpp_ins.print(str);
}
}
為了支持Python與Java的跨語言調(diào)用,我們需要對封裝好的接口生成動態(tài)庫,生成動態(tài)庫的方式有以下三種。
方式一:源碼依賴方式,將c_wrapper和C++代碼一起編譯生成libstr_print.so。這種方式業(yè)務方只需要依賴一個so,使用成本較小,但是需要獲取到C++源碼。對于一些現(xiàn)成的動態(tài)庫,可能不適用。
g++ -o libstr_print.so str_print.cpp c_wrapper.cpp -fPIC -shared
方式二:動態(tài)鏈接方式,這種方式生成的libstr_print.so,發(fā)布時需要攜帶上其依賴庫libstr_print_cpp.so。業(yè)務方需要同時依賴兩個so,使用的成本相對要高,但是不必提供原動態(tài)庫的源碼。
g++ -o libstr_print.so c_wrapper.cpp -fPIC -shared -L. -lstr_print_cpp
方式三:靜態(tài)鏈接方式,這種方式生成的libstr_print.so,發(fā)布時無需攜帶上libstr_print_cpp.so。業(yè)務方只需依賴一個so,不必依賴源碼,但是需要提供靜態(tài)庫。
g++ c_wrapper.cpp libstr_print_cpp.a -fPIC -shared -o libstr_print.so
上述三種方式,各自有適用場景和優(yōu)缺點。在我們本次的業(yè)務場景下,因為工具庫與封裝庫均由我們自己開發(fā),能夠獲取到源碼,因此選擇第一種方式,業(yè)務方依賴更加簡單。
Python標準庫自帶的ctypes可以實現(xiàn)加載C的動態(tài)庫的功能,使用方法如下:
str_print.py
# -*- coding: utf-8 -*-
import ctypes
# 加載 C lib
lib = ctypes.cdll.LoadLibrary("./libstr_print.so")
# 接口參數(shù)類型映射
lib.str_print.argtypes = [ctypes.c_char_p]
lib.str_print.restype = None
# 調(diào)用接口
lib.str_print('Hello World')
LoadLibrary會返回一個指向動態(tài)庫的實例,通過它可以在Python里直接調(diào)用該庫中的函數(shù)。argtypes與restype是動態(tài)庫中函數(shù)的參數(shù)屬性,前者是一個ctypes類型的列表或元組,用于指定動態(tài)庫中函數(shù)接口的參數(shù)類型,后者是函數(shù)的返回類型(默認是c_int,可以不指定,對于非c_int型需要顯示指定)。該部分涉及到的參數(shù)類型映射,以及如何向函數(shù)中傳遞struct、指針等高級類型,可以參考附錄中的文檔。
Java調(diào)用C lib有JNI與JNA兩種方式,從使用便捷性來看,更推薦JNA方式。
Java從1.1版本開始支持JNI接口協(xié)議,用于實現(xiàn)Java語言調(diào)用C/C++動態(tài)庫。JNI方式下,前文提到的c_wrapper模塊不再適用,JNI協(xié)議本身提供了適配層的接口定義,需要按照這個定義進行實現(xiàn)。JNI方式的具體接入步驟為:
Java代碼里,在需要跨語言調(diào)用的方法上,增加native關(guān)鍵字,用以聲明這是一個本地方法。
import java.lang.String;
public class JniDemo {
public native void print(String text);
}
通過javah命令,將代碼中的native方法生成對應的C語言的頭文件。這個頭文件類似于前文提到的c_wrapper作用。
javah JniDemo
得到的頭文件如下(為節(jié)省篇幅,這里簡化了一些注釋和宏):
#include
#ifdef __cplusplus
extern "C" {
#endif
JNIEXPORT void JNICALL Java_JniDemo_print
(JNIEnv *, jobject, jstring);
#ifdef __cplusplus
}
#endif
jni.h在JDK中提供,其中定義了Java與C語言調(diào)用所必需的相關(guān)實現(xiàn)。
JNIEXPORT和JNICALL是JNI中定義的兩個宏,JNIEXPORT標識了支持在外部程序代碼中調(diào)用該動態(tài)庫中的方法,JNICALL定義了函數(shù)調(diào)用時參數(shù)的入棧出棧約定。
Java_JniDemo_print是一個自動生成的函數(shù)名,它的格式是固定,由Java_{className}_{methodName}構(gòu)成,JNI會按照這個約定去注冊Java方法與C函數(shù)的映射。
三個參數(shù)里,前兩個是固定的。JNIEnv中封裝了jni.h里的一些工具方法,jobject指向Java中的調(diào)用類,即JniDemo,通過它可以找到Java里class中的成員變量在C的堆棧中的拷貝。jstring指向傳入?yún)?shù)text,這是對于Java中String類型的一個映射。有關(guān)類型映射的具體內(nèi)容,會在后文詳細展開。
編寫實現(xiàn)Java_JniDemo_print方法。
JniDemo.cpp
#include
#include "JniDemo.h"
#include "str_print.h"
JNIEXPORT void JNICALL Java_JniDemo_print (JNIEnv *env, jobject obj, jstring text)
{
char* str=(char*)env->GetStringUTFChars(text,JNI_FALSE);
std::string tmp = str;
StrPrint ins;
ins.print(tmp);
}
編譯生成動態(tài)庫。
g++ -o libJniDemo.so JniDemo.cpp str_print.cpp -fPIC -shared -I<$JAVA_HOME>/include/ -I<$JAVA_HOME>/include/linux
編譯運行。
java -Djava.library.path=JniDemo
JNI機制通過一層C/C++的橋接,實現(xiàn)了跨語言調(diào)用協(xié)議。這一功能在Android系統(tǒng)中一些圖形計算相關(guān)的Java程序下有著大量應用。一方面能夠通過Java調(diào)用大量操作系統(tǒng)底層庫,極大的減少了JDK上的驅(qū)動開發(fā)的工作量,另一方面能夠更充分的利用硬件性能。但是通過3.1.5.1中的描述也可以看到,JNI的實現(xiàn)方式本身的實現(xiàn)成本還是比較高的。尤其橋接層的C/C++代碼的編寫,在處理復雜類型的參數(shù)傳遞時,開發(fā)成本較大。為了優(yōu)化這個過程,Sun公司主導了JNA(Java Native Access)開源工程的工作。
JNA是在JNI基礎上實現(xiàn)的編程框架,它提供了C語言動態(tài)轉(zhuǎn)發(fā)器,實現(xiàn)了Java類型到C類型的自動轉(zhuǎn)換。因此,Java開發(fā)人員只要在一個Java接口中描述目標native library的函數(shù)與結(jié)構(gòu),不再需要編寫任何Native/JNI代碼,極大的降低了Java調(diào)用本地共享庫的開發(fā)難度。
JNA的使用方法如下:
在Java項目中引入JNA庫。
com.sun.jna
jna
5.4.0
聲明與動態(tài)庫對應的Java接口類。
public interface CLibrary extends Library {
void str_print(String text); // 方法名和動態(tài)庫接口一致,參數(shù)類型需要用Java里的類型表示,執(zhí)行時會做類型映射,原理介紹章節(jié)會有詳細解釋
}
加載動態(tài)鏈接庫,并實現(xiàn)接口方法。
JnaDemo.java
package com.jna.demo;
import com.sun.jna.Library;
import com.sun.jna.Native;
public class JnaDemo {
private CLibrary cLibrary;
public interface CLibrary extends Library {
void str_print(String text);
}
public JnaDemo() {
cLibrary = Native.load("str_print", CLibrary.class);
}
public void str_print(String text)
{
cLibrary.str_print(text);
}
}
對比可以發(fā)現(xiàn),相比于JNI,JNA不再需要指定native關(guān)鍵字,不再需要生成JNI部分C代碼,也不再需要顯示的做參數(shù)類型轉(zhuǎn)化,極大地提高了調(diào)用動態(tài)庫的效率。
為了做到開箱即用,我們將動態(tài)庫與對應語言代碼打包在一起,并自動準備好對應依賴環(huán)境。這樣使用方只需要安裝對應的庫,并引入到工程中,就可以直接開始調(diào)用。這里需要解釋的是,我們沒有將so發(fā)布到運行機器上,而是將其和接口代碼一并發(fā)布至代碼倉庫,原因是我們所開發(fā)的工具代碼可能被不同業(yè)務、不同背景(非C++)團隊使用,不能保證各個業(yè)務方團隊都使用統(tǒng)一的、標準化的運行環(huán)境,無法做到so的統(tǒng)一發(fā)布、更新。
Python可以通過setuptools將工具庫打包,發(fā)布至pypi公共倉庫中。具體操作方法如下:創(chuàng)建目錄。
.
├── MANIFEST.in #指定靜態(tài)依賴
├── setup.py # 發(fā)布配置的代碼
└── strprint # 工具庫的源碼目錄
├── __init__.py # 工具包的入口
└── libstr_print.so # 依賴的c_wrapper 動態(tài)庫
編寫__init__.py, 將上文代碼封裝成方法。
# -*- coding: utf-8 -*-
import ctypes
import os
import sys
dirname, _ = os.path.split(os.path.abspath(__file__))
lib = ctypes.cdll.LoadLibrary(dirname + "/libstr_print.so")
lib.str_print.argtypes = [ctypes.c_char_p]
lib.str_print.restype = None
def str_print(text):
lib.str_print(text)
編寫setup.py。
from setuptools import setup, find_packages
setup(
name="strprint",
version="1.0.0",
packages=find_packages(),
include_package_data=True,
description='str print',
author='xxx',
package_data={
'strprint': ['*.so']
},
)
編寫MANIFEST.in。
include strprint/libstr_print.so
打包發(fā)布。
python setup.py sdist upload
對于Java接口,將其打包成JAR包,并發(fā)布至Maven倉庫中。編寫封裝接口代碼JnaDemo.java。
package com.jna.demo;
import com.sun.jna.Library;
import com.sun.jna.Native;
import com.sun.jna.Pointer;
public class JnaDemo {
private CLibrary cLibrary;
public interface CLibrary extends Library {
Pointer create();
void str_print(String text);
}
public static JnaDemo create() {
JnaDemo jnademo = new JnaDemo();
jnademo.cLibrary = Native.load("str_print", CLibrary.class);
//System.out.println("test");
return jnademo;
}
public void print(String text)
{
cLibrary.str_print(text);
}
}
創(chuàng)建reso
創(chuàng)建resources目錄,并將依賴的動態(tài)庫放到該目錄。通過打包插件,將依賴的庫一并打包到JAR包中。
maven-assembly-plugin
false
jar-with-dependencies
make-assembly
package
assembly
安裝strprint包。
pip install strprint==1.0.0
使用示例:
# -*- coding: utf-8 -*-
import sys
from strprint import *
str_print('Hello py')
pom引入JAR包。
com.jna.demo
jnademo
1.0
使用示例:
JnaDemo jnademo = new JnaDemo();
jnademo.str_print("hello jna");
Python2與Python3版本的問題,是Python開發(fā)用戶一直詬病的槽點。因為工具面向不同的業(yè)務團隊,我們沒有辦法強制要求使用統(tǒng)一的Python版本,但是我們可以通過對工具庫做一下簡單處理,實現(xiàn)兩個版本的兼容。Python版本兼容里,需要注意兩方面的問題:
Python代碼的封裝里,基本不牽扯語法兼容問題,我們的工作主要集中在數(shù)據(jù)編碼問題上。由于Python 3的str類型使用的是unicode編碼,而在C中,我們需要的char* 是utf8編碼,因此需要對于傳入的字符串做utf8編碼處理,對于C語言返回的字符串,做utf8轉(zhuǎn)換成unicode的解碼處理。于是對于上例子,我們做了如下改造:
# -*- coding: utf-8 -*-
import ctypes
import os
import sys
dirname, _ = os.path.split(os.path.abspath(__file__))
lib = ctypes.cdll.LoadLibrary(dirname + "/libstr_print.so")
lib.str_print.argtypes = [ctypes.c_char_p]
lib.str_print.restype = None
def is_python3():
return sys.version_info[0] == 3
def encode_str(input):
if is_python3() and type(input) is str:
return bytes(input, encoding='utf8')
return input
def decode_str(input):
if is_python3() and type(input) is bytes:
return input.decode('utf8')
return input
def str_print(text):
lib.str_print(encode_str(text))
在很多情況下,我們調(diào)用的動態(tài)庫,會依賴其它動態(tài)庫,比如當我們依賴的gcc/g++版本與運行環(huán)境上的不一致時,時常會遇到glibc_X.XX not found的問題,這時需要我們提供指定版本的libstdc.so與libstdc++.so.6。
為了實現(xiàn)開箱即用的目標,在依賴并不復雜的情況下,我們會將這些依賴也一并打包到發(fā)布包里,隨工具包一起提供。對于這些間接依賴,在封裝的代碼里,并不需要顯式的load,因為Python與Java的實現(xiàn)里,加載動態(tài)庫,最終調(diào)用的都是系統(tǒng)函數(shù)dlopen。這個函數(shù)在加載目標動態(tài)庫時,會自動的加載它的間接依賴。所以我們所需要做的,就只是將這些依賴放置到dlopen能夠查找到路徑下。dlopen查找依賴的順序如下:
從上述查找順序中可以看出,對于依賴管理的最好方式,是通過指定LD_LIBRARY_PATH變量的方式,使其包含我們的工具包中的動態(tài)庫資源所在的路徑。另外,對于Java程序而言,我們也可以通過指定java.library.path運行參數(shù)的方式來指定動態(tài)庫的位置。Java程序會將java.library.path與動態(tài)庫文件名拼接到一起作為絕對路徑傳遞給dlopen,其加載順序排在上述順序之前。
最后,在Java中還有一個細節(jié)需要注意,我們發(fā)布的工具包是以JAR包形式提供,JAR包本質(zhì)上是一個壓縮包,在Java程序中,我們能夠直接通過Native.load()方法,直接加載位于項目resources目錄里的so,這些資源文件打包后,會被放到JAR包中的根目錄。
但是dlopen無法加載這個目錄。對于這一問題,最好的方案可以參考【3.1.3生成動態(tài)庫】一節(jié)中的打包方法,將依賴的動態(tài)庫合成一個so,這樣無須做任何環(huán)境配置,開箱即用。但是對于諸如libstdc++.so.6等無法打包在一個so的中系統(tǒng)庫,更為通用的做法是,在服務初始化時將so文件從JAR包中拷貝至本地某個目錄,并指定LD_LIBRARY_PATH包含該目錄。
實現(xiàn)方案一節(jié)中提到Python/Java不能直接調(diào)用C++接口,要先對C++中對外提供的接口用C語言的形式進行封裝。這里根本原因在于使用動態(tài)庫中的接口前,需要根據(jù)函數(shù)名查找接口在內(nèi)存中的地址,動態(tài)庫中函數(shù)的尋址通過系統(tǒng)函數(shù)dlsym實現(xiàn),dlsym是嚴格按照傳入的函數(shù)名尋址。
在C語言中,函數(shù)簽名即為代碼函數(shù)的名稱,而在C++語言中,因為需要支持函數(shù)重載,可能會有多個同名函數(shù)。為了保證簽名唯一,C++通過name mangling機制為相同名字不同實現(xiàn)的函數(shù)生成不同的簽名,生成的簽名會是一個像__Z4funcPN4printE這樣的字符串,無法被dlsym識別(注:Linux系統(tǒng)下可執(zhí)行程序或者動態(tài)庫多是以ELF格式組織二進制數(shù)據(jù),其中所有的非靜態(tài)函數(shù)(non-static)以“符號(symbol)”作為唯一標識,用于在鏈接過程和執(zhí)行過程中區(qū)分不同的函數(shù),并在執(zhí)行時映射到具體的指令地址,這個“符號”我們通常稱之為函數(shù)簽名)。
為了解決這個問題,我們需要通過extern "C" 指定函數(shù)使用C的簽名方式進行編譯。因此當依賴的動態(tài)庫是C++庫時,需要通過一個c_wrapper模塊作為橋接。而對于依賴庫是C語言編譯的動態(tài)庫時,則不需要這個模塊,可以直接調(diào)用。
C/C++函數(shù)調(diào)用的標準過程如下:
由以上過程可知,函數(shù)調(diào)用涉及內(nèi)存的申請釋放、實參到形參的拷貝等,Python/Java這種基于虛擬機運行的程序,在其虛擬機內(nèi)部也同樣遵守上述過程,但涉及到調(diào)用非原生語言實現(xiàn)的動態(tài)庫程序時,調(diào)用過程是怎樣的呢?
由于Python/Java的調(diào)用過程基本一致,我們以Java的調(diào)用過程為例來進行解釋,對于Python的調(diào)用過程不再贅述。
在Java的世界里,內(nèi)存由JVM統(tǒng)一進行管理,JVM的內(nèi)存由棧區(qū)、堆區(qū)、方法區(qū)構(gòu)成,在較為詳細的資料中,還會提到native heap與native stack,其實這個問題,我們不從JVM的角度去看,而是從操作系統(tǒng)層面出發(fā)來理解會更為簡單直觀。以Linux系統(tǒng)下為例,首先JVM名義上是一個虛擬機,但是其本質(zhì)就是跑在操作系統(tǒng)上的一個進程,因此這個進程的內(nèi)存會存在如下左圖所示劃分。而JVM的內(nèi)存管理實質(zhì)上是在進程的堆上進行重新劃分,自己又“虛擬”出Java世界里的堆棧。如右圖所示,native的棧區(qū)就是JVM進程的棧區(qū),進程的堆區(qū)一部分用于JVM進行管理,剩余的則可以給native方法進行分配使用。
圖 3
前文提到,native方法調(diào)用前,需要將其所在的動態(tài)庫加載到內(nèi)存中,這個過程是利用Linux的dlopen實現(xiàn)的,JVM會把動態(tài)庫中的代碼片段放到Native Code區(qū)域,同時會在JVM Bytecode區(qū)域保存一份native方法名與其所在Native Code里的內(nèi)存地址映射。
一次native方法的調(diào)用步驟,大致分為四步:
圖 4
由上述步驟可以看出,native方法的調(diào)用同樣涉及參數(shù)的拷貝,并且其拷貝是建立在JVM堆棧和原生堆棧之間。
對于原生數(shù)據(jù)類型,參數(shù)是通過值拷貝方式與native方法地址一起入棧。而對于復雜數(shù)據(jù)類型,則需要一套協(xié)議,將Java中的object映射到C/C++中能識別的數(shù)據(jù)字節(jié)。原因是JVM與C語言中的內(nèi)存排布差異較大,不能直接內(nèi)存拷貝,這些差異主要包括:
圖 5
上圖展示了native方法調(diào)用過程中參數(shù)傳遞的過程,其中映射拷貝在JNI中是由C/C++鏈接部分的膠水代碼實現(xiàn),類型的映射定義在jni.h中。
Java基本類型與C基本類型的映射(通過值傳遞。將Java對象在JVM內(nèi)存里的值拷貝至棧幀的形參位置):
typedef unsigned char jboolean;
typedef unsigned short jchar;
typedef short jshort;
typedef float jfloat;
typedef double jdouble;
typedef jint jsize;
Java復雜類型與C復雜類型的映射(通過指針傳遞。首先根據(jù)基本類型一一映射,將組裝好的新對象的地址拷貝至棧幀的形參位置):
typedef _jobject *jobject;
typedef _jclass *jclass;
typedef _jthrowable *jthrowable;
typedef _jstring *jstring;
typedef _jarray *jarray;
注:在Java中,非原生類型均是Object的派生類,多個object的數(shù)組本身也是一個object,每個object的類型是一個class,同時class本身也是一個object。
class _jobject {};
class _jclass : public _jobject {};
class _jthrowable : public _jobject {};
class _jarray : public _jobject {};
class _jcharArray : public _jarray {};
class _jobjectArray : public
當前題目:Linux下跨語言調(diào)用C++實踐
當前網(wǎng)址:http://uogjgqi.cn/article/ccccgsh.html

我們在微信上24小時期待你的聲音
解答本文疑問/技術(shù)咨詢/運營咨詢/技術(shù)建議/互聯(lián)網(wǎng)交流