本篇文章,我们继续前一篇的话题《使用 Golang 和 Docker 运行 Python 代码》,聊聊如何使用 Golang 和 Docker 将普通的 Python 软件包封装为高性能的服务。

写在前面

在上一篇内容中,我们提到过 Python 在 Golang 中运行,存在一些使用场景限制

如果我们在整个项目中直接引入这个方案,会让整个项目也受到相关的技术限制,影响开发和调试体验。

这个技术方案合适的实现场景,除了前文中直接封装为 Docker CLI 工具外,其实还有包装成独立可调用的网络服务。

本篇文章,我们就来聊聊这个思路的玩法,还是以前文中提到的 Python 项目 derek73/python-nameparser 为例。

本文中相关的代码,已经上传至 soulteary/go-nameparser,欢迎自取、“一键三连”,以及提交你的 PR。

程序实现的初步调整

在进行服务封装前,我们需要先针对昨天的程序进行基础的调整。

初步调整 Python 程序的实现

我们要封装的代码,依赖 Python 项目 python-nameparser。这个项目的使用方法,主要有两种:

  • 直接调用模块提供的 HumanName 来将我们想序列化的字符串进行改写,得到新的字符串。
  • 在调用 HumanName 之后,使用 .as_dict() 方法,将输出结果转换为字典类型,获取结构化信息。

如果使用代码来表示,和昨天文章中类似,会像下面这样:

from nameparser import HumanName

# 直接调用方法,获得序列化后的字符串
print(HumanName("Dr. Juan Q. Xavier de la Vega III (Doc Vega)"))

# 获取字符串的结构化数据
print(HumanName("Dr. Juan Q. Xavier de la Vega III (Doc Vega)").as_dict())

为了能够尽可能简单的实现跨程序数据交互,尽可能性价比高的完成内容逻辑处理。

我们可以将代码调整为类似下面这样,一次性获取两种不同场景的计算诉求,把两种计算结果组合在一起:

from nameparser import HumanName
import json

result = HumanName("Dr. Juan Q. Xavier de la Vega III (Doc Vega)")

data = {
    "text": str(result),
    "detail": result.as_dict()
}

print(json.dumps(data))

上面的程序执行完毕,我们将得到类似下面的结果:

{"text": "Dr. Juan Q. Xavier de la Vega III (Doc Vega)", "detail": {"title": "Dr.", "first": "Juan", "middle": "Q. Xavier", "last": "de la Vega", "suffix": "III", "nickname": "Doc Vega"}}

在实际使用的时候,我们只需要将上面程序中的占位文本替换为实际要处理的文本,就能搞定核心的计算逻辑啦。

初步调整 Golang 程序的实现

我们结合上文中的 Python 程序,将前文中的 Golang 代码也进行适当调整:

package main

import (
	"fmt"

	python3 "github.com/go-python/cpy3"
)

func main() {
	defer python3.Py_Finalize()
	python3.Py_Initialize()

	name := "Dr. Juan Q. Xavier de la Vega III (Doc Vega)"

	code := fmt.Sprintf(`
from nameparser import HumanName
import json

result = HumanName("%s")

data = {
	"text": str(result),
	"detail": result.as_dict()
}

print(json.dumps(data))
`, name)
	python3.PyRun_SimpleString(code)
}

Golang 这样,在程序执行的时候,就能够通过调整 Go 程序中的name 变量,来让 Python 程序计算不同的内容啦。

但是,如果我们坚持使用这样的方式,我们还需要封装一些额外的功能,来捕获程序运行过程中的日志内容。

对于这个程序或许还是可行的,因为交互数据不复杂,出错可能性也低,但依旧存在容易将无关紧要的日志填充到我们想要得到的结果中的可能性。

并且,如果这样封装的程序,对于维护依赖的 Python 程序而言,体验也并不好。如果我们的 Python 程序相对复杂,或者想要直接操作 Python 中的复杂的数据对象的话。

封装和使用 Python 软件包

为了解决这些问题,我们需要对程序进行进一步的封装和调整。

封装 Python 软件包

为了程序的使用和后续 Python 代码的维护更简单,我们需要将项目使用的 Python 代码封装成一个简单的 Python 模块。

我们在目录中创建一个名为 convert 的文件夹,然后在里面创建一个名为 convert.py 的 Python 程序,程序内容如下:

from nameparser import HumanName
import json

def Convert(s):
    result = HumanName(s)
    data = {
        "text": str(result),
        "detail": result.as_dict()
    }
    return json.dumps(data)

这样,我们就有了一个最简单的,能够使用模块方式调用的 Python 模块,能够调用我们需要使用的 HumanName 函数,并将传入的字符串序列化为我们想要的结果。

这部分代码保存在 soulteary/go-nameparser/convert,可以自取。

使用 Golang 直接调用 Python 包里的函数

当我们完成了 Python 模块的功能封装之后,我们需要完成两个函数,来让 Golang 能够自由调用我们封装 Python 模块中的方法,来进行具体的逻辑计算。

我们先来完成在 Go 中加载 Python 软件包的功能,能够加载指定目录的 Python 软件包到 Golang 程序的内存中:

func LoadModule(dir string) *python3.PyObject {
	path := python3.PyImport_ImportModule("sys").GetAttrString("path")
	python3.PyList_Insert(path, 0, python3.PyUnicode_FromString(dir))
	return python3.PyImport_ImportModule(filepath.Base(dir))
}

然后,来完成加载前文中,我们封装好的 Python “Convert” 模块和模块中的 Convert 函数功能:

func Convert(input string) string {
	module := LoadModule("./convert")
	function := module.GetAttrString("Convert")
	args := python3.PyTuple_New(1)
	python3.PyTuple_SetItem(args, 0, python3.PyUnicode_FromString(input))
	return python3.PyUnicode_AsUTF8(function.Call(args, python3.Py_None))
}

实际使用时,我们只要先初始化 Python 环境,然后调用 Go 中的 Convert 函数,就能够在 Go 中,调用 Python 模块进行计算啦:

package main

import (
	"fmt"
	"log"
	"path/filepath"

	python3 "github.com/datadog/go-python3"
)

func main() {
	defer python3.Py_Finalize()
	python3.Py_Initialize()
	if !python3.Py_IsInitialized() {
		log.Fatalln("Failed to initialize Python environment")
	}

	ret := Convert("Dr. Juan Q. Xavier de la Vega III (Doc Vega)")
	fmt.Printf(ret)
}

程序运行完毕之后,日志结果也是符合预期的。

 {"text": "Dr. Juan Q. Xavier de la Vega III (Doc Vega)", "detail": {"title": "Dr.", "first": "Juan", "middle": "Q. Xavier", "last": "de la Vega", "suffix": "III", "nickname": "Doc Vega"}}

针对 Python 输出结果进行结构化解析

在上面的程序中,使用 Go 执行 Python 函数,输出了字符串。如果我们想在 Golang 使用结构化的方式来访问数据字段,还需要进行一个简单的数据解析动作。

先定义一个数据结构,然后调用 json.Unmarshal 处理字符串即可:

...

type HumanName struct {
	Text   string `json:"text"`
	Detail struct {
		Title    string `json:"title"`
		First    string `json:"first"`
		Middle   string `json:"middle"`
		Last     string `json:"last"`
		Suffix   string `json:"suffix"`
		Nickname string `json:"nickname"`
	} `json:"detail"`
}

func main() {
	defer python3.Py_Finalize()
	python3.Py_Initialize()
	if !python3.Py_IsInitialized() {
		log.Fatalln("Failed to initialize Python environment")
	}

	ret := Convert("Dr. Juan Q. Xavier de la Vega III (Doc Vega)")

	var name HumanName
	err := json.Unmarshal([]byte(ret), &name)
	if err != nil {
		fmt.Println("Parsing JSON failed:", err)
		return
	}

	fmt.Println("Name:", name.Text)
	fmt.Println("Detail:", name.Detail)
}

程序运行完毕,我们就能够得到解析结果啦。以及在 Golang 程序中随意的使用这个来自 Python 的数据:

Name: Dr. Juan Q. Xavier de la Vega III (Doc Vega)
Detail: {Dr. Juan Q. Xavier de la Vega III Doc Vega}

实现可访问的 API

当我们能够随意解析和使用来自 Python 程序的计算结果后,就可以进行 API 接口的封装啦。

实现 HTTP 接口

实现 HTTP 接口并不难,如果我们想实现一个能够接收 POST 请求,对请求参数中的 name 字段进行计算的函数,代码实现类似下面,不到 30 行:

func Parse(input string) (ret HumanName, err error) {
	var name HumanName
	err = json.Unmarshal([]byte(Convert(input)), &name)
	if err != nil {
		return ret, fmt.Errorf("Parsing JSON failed: %v", err)
	}
	return name, nil
}

type Data struct {
	Name string `json:"name"`
}

route.POST("/api/convert", func(c *gin.Context) {
	var data Data
	if err := c.ShouldBindJSON(&data); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	result, err := Parse(data.Name)
	if err != nil {
		c.JSON(http.StatusInternalServerError, err)
		return
	}
	c.JSON(http.StatusOK, result)
})

在上面的代码中,我们封装了一个新的函数,能够将 Python 返回的内容序列化为对象,方便其他逻辑调用,比如本例中的 Gin 的接口返回时使用。

当然,如果我们 Python 结果能够确保是稳定的 JSON 结构输出,并且我们的 Go 程序不需要针对 Python 计算结果进行调整,那么我们也可以直接透传结果,不需要做数据的解析处理。

当我们完成最终服务端代码后,可以使用 curl 来验证接口:

curl --request POST 'http://127.0.0.1:8080/api/convert' --header 'Content-Type: application/json' --data-raw '{"name": "Dr. Juan Q. Xavier de la Vega III (Doc Vega)"}'

接口返回结果,自然也是符合预期的:

{"text":"Dr. Juan Q. Xavier de la Vega III (Doc Vega)","detail":{"title":"Dr.","first":"Juan","middle":"Q. Xavier","last":"de la Vega","suffix":"III","nickname":"Doc Vega"}}

封装 GRPC 接口

关于 GRPC 的快速上手攻略,官方已经些的很好了,这里就不过多赘述了。唯一需要注意的是你使用的工具版本和程序中的 GRPC 版本是否一致。在折腾 GRPC 之前,我们需要先全局安装两个工具(使用文档中的版本):

go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.28
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.2

完成准备工作后,我们根据官方建议来快速集成和实现下程序的 GRPC 接口。

先定义一个名为 message.proto 的 protobuf 文件,在里面包含我们要启动一个名为 Converter 的服务,服务公开暴露一个名为 HumanName 的方法,以及这个方法的入参和出参:

syntax = "proto3";

option go_package = "./pkg/pb";

package pb;

// The Converter service definition.
service Converter {
  // Sends a Converter request
  rpc HumanName (ConvertRequest) returns (ConvertReply) {}
}

// The request message containing the name.
message ConvertRequest {
  string name = 1;
}

// The response message containing the name parsed
message ConvertReply {
  string message = 1;
}

保存完毕文件之后,我们执行命令:

protoc --go_out=. --go-grpc_out=. *.proto

所需要的 GRPC 相关的程序就会自动生成完毕,并保存在上面配置指定的目录中:option go_package = "./pkg/pb";

最后,我们再用 30 行左右代码,实现调用上面生成好的 GRPC 接口的 GRPC 服务即可:

package rpc

import (
	"context"
	"log"
	"net"

	"github.com/soulteary/go-nameparser/internal/bridge"
	"github.com/soulteary/go-nameparser/internal/define"
	pb "github.com/soulteary/go-nameparser/pkg/pb"
	"google.golang.org/grpc"
)

type server struct {
	pb.UnimplementedConverterServer
}

func (s *server) HumanName(ctx context.Context, in *pb.ConvertRequest) (*pb.ConvertReply, error) {
	return &pb.ConvertReply{Message: bridge.Convert(in.GetName())}, nil
}

func Launch() {
	lis, err := net.Listen("tcp", define.GRPC_PORT)
	if err != nil {
		log.Fatalf("GRPC server failed to listen: %v", err)
	}
	s := grpc.NewServer()
	pb.RegisterConverterServer(s, &server{})
	log.Printf("GRPC server listening at %v", lis.Addr())

	if err := s.Serve(lis); err != nil {
		log.Fatalf("GRPC server failed to serve: %v", err)
	}
}

当我们完成了最终的 GRPC 服务程序之后,就能够对外提供 GRPC 服务啦。

至此,我们就完成了文章开头提到的内容,以及完成了我们封装的 Python 程序和调用程序的解耦。

通过 GRPC 方式调用服务

GRPC 服务的调用也很简单,我们只需要把上文中生成好的 “PB” 目录复制到我们的客户端程序目录中,然后使用下面的代码,即可调用上文中我们封装好的服务。GRPC 客户端的完整代码在这里

package main

import (
	"context"
	"flag"
	"log"
	"time"

	pb "grpc-client/pb"

	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
)

const (
	TestName = "Dr. Juan Q. Xavier de la Vega III (Doc Vega)"
	GrpcAddr = "localhost:8081"
)

func main() {
	flag.Parse()
	// Set up a connection to the server.
	conn, err := grpc.Dial(GrpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		log.Fatalf("did not connect: %v", err)
	}
	defer conn.Close()
	c := pb.NewConverterClient(conn)

	// Contact the server and print out its response.
	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()
	r, err := c.HumanName(ctx, &pb.ConvertRequest{Name: TestName})
	if err != nil {
		log.Fatalf("could not greet: %v", err)
	}
	log.Printf("Greeting: %s", r.GetMessage())
}

当服务端启动完毕后,我们使用客户端进行调用,就能够得到下面的复合预期的结果啦。

2023/05/22 22:56:58 Greeting: {"text": "Dr. Juan Q. Xavier de la Vega III (Doc Vega)", "detail": {"title": "Dr.", "first": "Juan", "middle": "Q. Xavier", "last": "de la Vega", "suffix": "III", "nickname": "Doc Vega"}}

改进 Docker 镜像

相比较前文,本篇文章中,我们的项目目录和依赖相对复杂。就不再相对适用在 Docker 中动态初始化项目依赖和进行依赖下载了,会浪费太多时间。

所以,我们可以调整实现,来加速镜像的构建:

# Base Images
FROM golang:1.20.4-alpine3.18 AS go-builder
FROM python:3.7-alpine3.18 AS builder
# Base Builder Env
COPY --from=go-builder /usr/local/go/ /usr/local/go/
ENV PATH="/usr/local/go/bin:${PATH}"
RUN python -m pip install --upgrade pip
RUN apk add build-base pkgconfig
ENV PKG_CONFIG_PATH=/usr/local/lib/pkgconfig/
ENV CGO_ENABLED=1
# Copy source code
# Install deps
RUN pip install nameparser
COPY ./go.* /app/
WORKDIR /app
RUN go mod tidy
COPY . ./
# Build the binary
RUN go build -o HumanName

# Run Image
FROM python:3.7-alpine3.18
# Copy Python Deps
COPY --from=builder /usr/local/lib/python3.7/site-packages/nameparser /usr/local/lib/python3.7/site-packages/nameparser
COPY --from=builder /app/convert /convert
# Copy binary
COPY --from=builder /app/HumanName /HumanName
CMD ["/HumanName"]

使用预构建容器镜像

针对本文中提到的服务,我已经构建了一个镜像,并推送到了 DockerHub 中。如果你感兴趣,可以执行下面的命令,启动它,以及进行一些基础的性能测试 :D

docker run --rm -it -p 8080:8080 -p 8081:8081 soulteary/go-nameparser

最后

好了,这篇文章就先聊到这里啦。

有机会我们展开聊聊另外两种更强力的方案 :-D

–EOF