【grpc】利用protobuf实现java或kotlin调用python脚本,含实现过程和全部代码

前言

在一些特殊场景中,我们可能需要使用java或者其他任意语言调用python脚本或sdk等。本文的需求衍生也不例外于此,python端有sdk,但只能在python中调用,于是就有了本文章。

常见的调用方式如jython、python提供http rest接口、python提供rpc实现、java通过jni调用转换成c的python。每种调用方式都有优缺点,我们更期待一种简单、快速、功能更自由、低侵入、方便维护的方式来实现。

快速调研了一下现有的各种实现方式,最后决定采用grpc调用,好处就是代码不多,协议定义简单方便,两端协调好就可以了,非常适合对sdk、算法、脚本、服务的调用,缺点就是更改协议后,两边要重新生成代码来保持同步,不过在有现成插件的情况下,这能很方便的控制,话不多说,下面贴出详细做法。

一、定义proto文件

创建一个文件名为script.proto,稍后需要在java端和python端引入

//@ 1 使用proto3语法
syntax = "proto3";
//@ 2 生成多个类(一个类便于管理)
option java_multiple_files = false;
//@ 3 定义调用时的java包名
option java_package= "com.kamjin.javacallpython.grpc.demo.proto";
//@ 4 生成外部类名
option java_outer_classname = "ScriptProto";
//@ 6. proto包名称(逻辑包名称)
package script;
import "google/protobuf/struct.proto";
//@ 7 定义一个服务来描述要生成的API接口,类似于Java的业务逻辑接口类
service ScriptService{ //定义执行方法,方法名称和参数和返回值都是大驼峰
    //Note: 这里是 returns,不是 return
    rpc Execute (ScriptRequest) returns (ScriptResponse) {}
}
//@ 8 定义请求数据结构
//字符串数据类型
//等号后面的数字即索引值(表示参数顺序,以防止参数传递顺序混乱),服务启动后无法更改
//不能使用19000-1999保留数字
message ScriptRequest{ string content = 1;
    google.protobuf.ListValue extract_params = 2;
}
//@ 9 定义响应数据结构
message ScriptResponse{ string result = 1;
}

二、java/kotlin端

个人习惯使用kotlin+gradle,此处使用该组合演示,java+maven也可以,主要是gradle配置部分区别较大,有需求可以评论区留言

0.创建服务

创建一个springboot项目,版本为2.x,为了方便起见,需要是web服务,端口默认就可以

1.安装protobuf插件

在IDEA插件市场搜索protobuf下载安装,注意作者是HIGAN,不要装错了,如图

2.依赖和其他配置

配置模块的build.gradle.kts文件,

新增依赖和plugin如下:

plugins { //protobuf plugin
    id("com.google.protobuf") version "0.9.4"
    
    ...
}
dependencies { //grpc client
    implementation("net.devh:grpc-client-spring-boot-starter:2.15.0.RELEASE")
    implementation("io.grpc:grpc-stub:1.15.1")
    implementation("io.grpc:grpc-protobuf:1.15.1")
    
	...
}

protobuf配置和task配置如下:

import com.google.protobuf.gradle.*
import org.gradle.kotlin.dsl.proto
//https://github.com/google/protobuf-gradle-plugin
sourceSets { main { proto { srcDir("src/main/proto")
            include("**/*.proto")
        }
    }
    test { proto { srcDir("src/test/proto")
        }
    }
}
protobuf { protoc { // The artifact spec for the Protobuf Compiler
        artifact = "com.google.protobuf:protoc:3.17.3"
    }
    plugins { // Optional: an artifact spec for a protoc plugin, with "grpc" as
        // the identifier, which can be referred to in the "plugins"
        // container of the "generateProtoTasks" closure.
        id("grpc") { artifact = "io.grpc:protoc-gen-grpc-java:1.40.0"
        }
    }
    generateProtoTasks { ofSourceSet("main").forEach { it.plugins { // Apply the "grpc" plugin whose spec is defined above, without
                // options. Note the braces cannot be omitted, otherwise the
                // plugin will not be added. This is because of the implicit way
                // NamedDomainObjectContainer binds the methods.
                id("grpc")
            }
        }
    }
}
//配置提示proto文件重复的处理策略
tasks.withType { duplicatesStrategy = DuplicatesStrategy.INCLUDE
}

配置完成后点一下gradle的刷新按钮reload all gradle projects,此时会下载相关依赖

3.生成代码

在模块的src/main目录下新建名为proto文件夹,将定义好的script.proto文件放入该目录,运行gradle task,如图所示:

运行该task后将会生成可以调用的proto服务代码,将在文件夹build/generated/source/proto/main可以找到生成的代码,一般无需改动该代码,我们需要使用时直接调用引入即可。

4.服务配置

在模块配置文件application.yaml中配置如下:

grpc:
  client:
    scriptServiceGrpc:
      address: 'static://127.0.0.1:50051'
      negotiationType: plaintext
  • scriptServiceGrpc是我们在代码里需要声明的grpc server名称,可以任意自定义和在grpc.client下定义多个这样的条目
  • address指定grpc server端的地址+端口,在当前文章中对应的就是python项目中的grpc服务URL地址

    关于配置项的更多详情可以查看这里。

    5.编写grpc client代码

    首先编写一个controller用于调试代码

    package com.kamjin.javacallpython.grpc.demo.controller.test
    import com.kamjin.javacallpython.grpc.demo.handle.*
    import com.kamjin.common.ext.*
    import org.springframework.beans.factory.annotation.*
    import org.springframework.web.bind.annotation.*
    /**
     * 

    * *

    * * @author kam * @since 2024/01/08 */ @RequestMapping("/test/proto/") @RestController class ProtoTestController { @Autowired lateinit var grpcScriptExecuter: GrpcScriptExecuter @PostMapping("script") fun script(@RequestBody request: MutableMap): String? { val contentBase64 = request["content_base64"] as String? ?: return "" return this.grpcScriptExecuter.exec( ScriptContent( content = contentBase64.base64Decode(), extractParams = request["extract_params"] as List? ?: mutableListOf() ) ).result } }

    执行脚本的GrpcScriptExecuter,内容如下:

    package com.kamjin.javacallpython.grpc.demo.handle
    import com.google.protobuf.*
    import com.kamjin.javacallpython.grpc.demo.proto.*
    import net.devh.boot.grpc.client.inject.*
    import org.springframework.stereotype.*
    /**
     * 

    * *

    * * @author kam * @since 2024/01/08 */ interface ScriptExecute { fun exec(content: ScriptContent): ScriptExecResult } data class ScriptContent( val content: String, val extractParams: List = mutableListOf() ) data class ScriptExecResult(val result: String? = null) @Component class GrpcScriptExecuter : ScriptExecute { @GrpcClient("scriptServiceGrpc") private lateinit var scriptStub: ScriptServiceGrpc.ScriptServiceBlockingStub override fun exec(content: ScriptContent): ScriptExecResult { val c = content.content if (c.isBlank()) return ScriptExecResult() val extractParams = content.extractParams val r = ScriptProto.ScriptRequest.newBuilder() .setContent(c) .apply { if (extractParams.isNotEmpty()) { this.extractParams = ListValue.newBuilder().apply { for (ep in extractParams) { this.addValues( Value.newBuilder().setStringValue(ep) .build() ) } } .build() } } .build() try { return ScriptExecResult(scriptStub.execute(r).result) } catch (e: io.grpc.StatusRuntimeException) { throw RuntimeException("script exec error,msg: ${e.message}", e) } } }
    • @GrpcClient("scriptServiceGrpc")的值对应的则是上一步中在appliation.yaml中配置的值
    • 当前文件做了两件事:

      1.定义一个ScriptExecute的interface和请求/响应的data class

      2.实现了GrpcScriptExecuter,用于通过调用grpc server端执行脚本内容

      这样就完成了java端grpc client的创建。

      三、python端

      0.安装protobuf插件

      同样需要安装protobuf插件,上文已经描述过了(idea plugin)不再赘述

      1.创建项目

      创建一个python venv项目,在模块中创建一个新的文件夹:proto_test

      2.复制proto文件

      把之前定义的script.proto文件复制到其中,要求和java服务端放入的文件保持一致,不用做任何改动。

      3.生成代码

      转到控制台,使用pip安装需要的依赖

      pip install grpcio
      pip install grpcio-tools googleapis-common-protos
      

      然后进入proto_test目录,生成相应的grpc代码

      python -m grpc_tools.protoc -I . --python_out=. --grpc_python_out=. script.proto
      

      此时会在proto_test目录下生成文件:script_pb2_grpc.py、script_pb2.py,后面会用到。

      4.编写grpc server代码

      创建文件:script_server.py,内容如下:

      import json
      import grpc
      import script_pb2
      import script_pb2_grpc
      from concurrent import futures
      import time
      _ONE_DAY_IN_SECONDS = 60 * 60 * 24
      # service impl
      class ScriptServicer(script_pb2_grpc.ScriptServiceServicer):
          def Execute(self, request, context):
              s = request.content
              result = {}
              print("content: %s" % s)
              exec(s, result)
              # 根据传入的参数提取值
              data = {}
              for p in request.extract_params:
                  data[p] = result.get(p, None)
              return script_pb2.ScriptResponse(result=json.dumps(data))
      def serve():
          server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
          script_pb2_grpc.add_ScriptServiceServicer_to_server(ScriptServicer(), server)
          server.add_insecure_port('[::]:50051')
          server.start()
          try:
              while True:
                  time.sleep(_ONE_DAY_IN_SECONDS)
          except KeyboardInterrupt:
              server.stop(0)
      if __name__ == '__main__':
          serve()
      

      这样就完成了python端grpc server的创建。

      四、验证

      1.启动java服务:通过IDEA运行WEB服务

      2.启动python服务:python script_server.py

      3.使用postman或者IDEA httpclient调用接口,这里使用IDEA的http client

      定义文件javacallpython-grpc.http:

      POST http://localhost:8080/test/proto/script
      Content-Type: application/json
      { "content_base64": "aW1wb3J0IG1hdGgKZGVmIGZ1biAobik6CiAgICBkYXRhID0gbgogICAgZGF0YSA9IGRhdGEgKiBtYXRoLnBpCiAgICByZXR1cm4gZGF0YQpyID0gZnVuKDEwKQ==",
        "extract_params": ["r"]
      }
      

      运行该调用,这将会调用刚刚启动的web服务(端口为8080默认)接口:/test/proto/script

      • 此处传的content_base64是因为json中不支持’‘’‘’'标注的字符串,也就没法满足python的缩进要求,故将脚本内容转为base64传入,实际脚本内容为:
        import math
        def fun (n):
            data = n
            data = data * math.pi
            return data
        r = fun(10)
        

        转为base64后:

        aW1wb3J0IG1hdGgKZGVmIGZ1biAobik6CiAgICBkYXRhID0gbgogICAgZGF0YSA9IGRhdGEgKiBtYXRoLnBpCiAgICByZXR1cm4gZGF0YQpyID0gZnVuKDEwKQ==
        
        • extract_params是表明我们需要提取脚本中变量名称为r的内容的值作为脚本执行结果返回。

          python端控制台打印:

          http client执行结果:

          这表明带import的脚本执行成功,并正确返回了我们想要提取的值

          参考文章

          1.拥抱云原生,Java与Python基于gRPC通信

          2.base64和字符串互转

          3.Import Lib not working with exec function?

          4.yidongnan/grpc-spring-boot-starter

          5.google/protobuf-gradle-plugin

          结语

          本文实现了通过grpc在java端传入脚本内容,在python端执行的脚本的实现方法,性能状况未测试,后续如果有时间会对其进行使用验证,如果发现问题,可以做相关改进,会在本文进行更新,本文的实现对实际项目中的使用具有一定的参考价值。

          后面会继续更新分享更多相关内容,请多多关注~

          最后,各位看众可以思考一下:

          为什么以上做法可以成功执行带import的脚本?