1 背景
查询理解(QU, Query Understanding)是美团搜索的核心模块,主要职责是理解用户查询,生成查询意图、成分、改写等基础信号,应用于搜索的召回、排序、展示等多个环节,对搜索基础体验至关重要。该服务的线上主体程序基于C++语言开发,服务中会加载大量的词表数据、预估模型等,这些数据与模型的离线生产过程有很多文本解析能力需要与线上服务保持一致,从而保证效果层面的一致性,如文本归一化、分词等。
而这些离线生产过程通常用Python与Java实现。如果在线、离线用不同语言各自开发一份,则很难维持策略与效果上的统一。同时这些能力会有不断的迭代,在这种动态场景下,不断维护多语言版本的效果打平,给我们的日常迭代带来了极大的成本。因此,我们尝试通过跨语言调用动态链接库的技术解决这个问题,即开发一次基于C++的so,通过不同语言的链接层封装成不同语言的组件库,并投入到对应的生成过程。这种方案的优势非常明显,主体的业务逻辑只需要开发一次,封装层只需要极少量的代码,主体业务迭代升级,其它语言几乎不需要改动,只需要包含最新的动态链接库,发布最新版本即可。同时C++作为更底层的语言,在很多场景下,它的计算效率更高,硬件资源利用率更高,也为我们带来了一些性能上的优势。
本文对我们在实际生产中尝试这一技术方案时,遇到的问题与一些实践经验做了完整的梳理,希望能为大家提供一些参考或帮助。
2 方案概述
为了达到业务方开箱即用的目的,综合考虑C++、Python、Java用户的使用习惯,我们设计了如下的协作结构:
图 1
3 实现详情
Python、Java支持调用C接口,但不支持调用C++接口,因此对于C++语言实现的接口,必须转换为C语言实现。为了不修改原始C++代码,在C++接口上层用C语言进行一次封装,这部分代码通常被称为“胶水代码”(Glue Code)。具体方案如下图所示:
图 2
本章节各部分内容如下:
- 【功能代码】部分,通过打印字符串的例子来讲述各语言部分的编码工作。
- 【打包发布】部分,介绍如何将生成的动态库作为资源文件与Python、Java代码打包在一起发布到仓库,以降低使用方的接入成本。
- 【业务使用】部分,介绍开箱即用的使用示例。
- 【易用性优化】部分,结合实际使用中遇到的问题,讲述了对于Python版本兼容,以及动态库依赖问题的处理方式。
3.1 功能代码
3.1.1 C++代码
作为示例,实现一个打印字符串的功能。为了模拟实际的工业场景,对以下代码进行编译,分别生成动态库 libstr_print_cpp.so
、静态库libstr_print_cpp.a
。
str_print.h
#pragma once
#include <string>
class StrPrint {
public:
void print(const std::string& text);
};
str_print.cpp
#include <iostream>
#include "str_print.h"
void StrPrint::print(const std::string& text) {
std::cout << text << std::endl;
}
3.1.2 c_wrapper代码
如上文所述,需要对C++库进行封装,改造成对外提供C语言格式的接口。
c_wrapper.cpp
#include "str_print.h"
extern "C" {
void str_print(const char* text) {
StrPrint cpp_ins;
std::string str = text;
cpp_ins.print(str);
}
}
3.1.3 生成动态库
为了支持Python与Java的跨语言调用,我们需要对封装好的接口生成动态库,生成动态库的方式有以下三种
- 方式一:源码依赖方式,将c_wrapper和C++代码一起编译生成
libstr_print.so
。这种方式业务方只需要依赖一个so,使用成本较小,但是需要获取到源码。对于一些现成的动态库,可能不适用。
g++ -o libstr_print.so str_print.cpp c_wrapper.cpp -fPIC -shared
- 方式二:动态链接方式,这种方式生成的
libstr_print.so
,发布时需要携带上其依赖库libstr_print_cpp.so
。 这种方式,业务方需要同时依赖两个so,使用的成本相对要高,但是不必提供原动态库的源码。
g++ -o libstr_print.so c_wrapper.cpp -fPIC -shared -L. -lstr_print_cpp
- 方式三:静态链接方式,这种方式生成的
libstr_print.so
,发布时无需携带上libstr_print_cpp.so
。 这种方式,业务方只需依赖一个so,不必依赖源码,但是需要提供静态库。
g++ c_wrapper.cpp libstr_print_cpp.a -fPIC -shared -o libstr_print.so
上述三种方式,各自有适用场景和优缺点。在我们本次的业务场景下,因为工具库与封装库均由我们自己开发,能够获取到源码,因此选择第一种方式,业务方依赖更加简单。
3.1.4 Python接入代码
Python标准库自带的ctypes可以实现加载C的动态库的功能,使用方法如下:
str_print.py
# -*- coding: utf-8 -*-
import ctypes
# 加载 C lib
lib = ctypes.cdll.LoadLibrary("./libstr_print.so")
# 接口参数类型映射
lib.str_print.argtypes = [ctypes.c_char_p]
lib.str_print.restype = None
# 调用接口
lib.str_print('Hello World')
LoadLibrary会返回一个指向动态库的实例,通过它可以在Python里直接调用该库中的函数。argtypes与restype是动态库中函数的参数属性,前者是一个ctypes类型的列表或元组,用于指定动态库中函数接口的参数类型,后者是函数的返回类型(默认是c_int,可以不指定,对于非c_int型需要显示指定)。该部分涉及到的参数类型映射,以及如何向函数中传递struct、指针等高级类型,可以参考附录中的文档。
3.1.5 Java接入代码
Java调用C lib有JNI与JNA两种方式,从使用便捷性来看,更推荐JNA方式。
3.1.5.1 JNI接入
Java从1.1版本开始支持JNI接口协议,用于实现Java语言调用C/C++动态库。JNI方式下,前文提到的c_wrapper模块不再适用,JNI协议本身提供了适配层的接口定义,需要按照这个定义进行实现。JNI方式的具体接入步骤为:
Java代码里,在需要跨语言调用的方法上,增加native关键字,用以声明这是一个本地方法。
import java.lang.String;
public class JniDemo {
public native void print(String text);
}
通过javah命令,将代码中的native方法生成对应的C语言的头文件。这个头文件类似于前文提到的c_wrapper作用。
javah JniDemo
得到的头文件如下(为节省篇幅,这里简化了一些注释和宏):
#include <jni.h>
#ifdef __cplusplus
extern "C" {
#endif
JNIEXPORT void JNICALL Java_JniDemo_print
(JNIEnv *, jobject, jstrin