客户端
服务器在 8000 端口提供 HTTP,在 50051 端口提供 gRPC,由同一个二进制同时服务。HTTP 是默认选项,95% 的用户用的就是它 —— 下面这些范例覆盖持久连接和并发分发。gRPC 放在页面底部的进阶章节。
每个 endpoint、查询参数和响应 schema 都在 API 参考 里。本页讲 API 参考讲不到的:keep-alive 设置、并发分发、protobuf 代码生成。
HTTP
请使用长生命周期的客户端对象。每次都新建请求会反复付出 TCP 握手成本,并可能压垮服务器。只要复用客户端,所有标准 HTTP 库都会默认复用底层连接。
Python
进程启动时构造一个 requests.Session 并全局共享。Session 内部维护一个 TCP 连接池并跨调用复用,因此密集循环里 TCP 握手只发生一次。
pip install "requests>=2.32"import requests
SESSION = requests.Session()SESSION.headers.update({"Connection": "keep-alive"})BASE_URL = "http://localhost:8000"
def ocr_raw(path: str, layout: bool = False) -> dict: with open(path, "rb") as f: data = f.read() r = SESSION.post( f"{BASE_URL}/ocr/raw", data=data, headers={"Content-Type": "image/png"}, params={"layout": 1} if layout else None, timeout=30, ) r.raise_for_status() return r.json()
def ocr_pdf(path: str, mode: str = "ocr", dpi: int = 100) -> dict: with open(path, "rb") as f: data = f.read() r = SESSION.post( f"{BASE_URL}/ocr/pdf", data=data, params={"mode": mode, "dpi": dpi}, timeout=120, ) r.raise_for_status() return r.json()
print(ocr_raw("invoice.png"))要并发分发,把同一个 SESSION 通过 ThreadPoolExecutor 派发即可。在下面这种简单 POST 模式下 Session 是线程安全的,worker 数量也直接决定服务器同时看到的在途请求数。
from concurrent.futures import ThreadPoolExecutor
def ocr_many(paths: list[str], workers: int = 8) -> list[dict]: with ThreadPoolExecutor(max_workers=workers) as pool: return list(pool.map(ocr_raw, paths))
results = ocr_many(["a.png", "b.png", "c.png", "d.png"])Java
整个进程一个 HttpClient。它内部就带连接池,线程安全,自动协商 HTTP/1.1 keep-alive。每次调用 HttpClient.newHttpClient() 等于扔掉连接池、重新开 socket。目标 Java 21+;并发示例用虚拟线程。
import java.net.URI;import java.net.http.HttpClient;import java.net.http.HttpRequest;import java.net.http.HttpResponse;import java.nio.file.Files;import java.nio.file.Path;import java.time.Duration;
public final class TurboOcr { public static final HttpClient HTTP = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .build();
public static String ocrRaw(Path image) throws Exception { var req = HttpRequest.newBuilder(URI.create("http://localhost:8000/ocr/raw")) .header("Content-Type", "image/png") .timeout(Duration.ofSeconds(30)) .POST(HttpRequest.BodyPublishers.ofByteArray(Files.readAllBytes(image))) .build(); return HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body(); }
public static void main(String[] args) throws Exception { System.out.println(ocrRaw(Path.of("invoice.png"))); }}并发分发时,每张图片向虚拟线程执行器提交一个任务。共享的 HttpClient 让阻塞调用的并发成本极低;几千个在途请求也不会消耗额外的平台线程。
import java.nio.file.Path;import java.util.List;import java.util.concurrent.Executors;import java.util.concurrent.Future;
public class FanOut { public static void main(String[] args) throws Exception { var paths = List.of(Path.of("a.png"), Path.of("b.png"), Path.of("c.png")); try (var pool = Executors.newVirtualThreadPerTaskExecutor()) { List<Future<String>> futures = paths.stream() .map(p -> pool.submit(() -> TurboOcr.ocrRaw(p))) .toList(); for (var f : futures) System.out.println(f.get()); } }}C++
每个 worker 线程持有一个 CURL* easy handle,存活时长等于进程。只要 handle 还在,libcurl 就会复用底层 TCP 连接。CURLOPT_TCP_KEEPALIVE 可避免 NAT 网关静默回收空闲 socket。编译加 -std=c++20。
apt install libcurl4-openssl-dev// Compile: g++ -std=c++20 client.cc -lcurl#include <curl/curl.h>#include <fstream>#include <sstream>#include <stdexcept>#include <string>
static size_t write_cb(char* ptr, size_t size, size_t nmemb, void* ud) { static_cast<std::string*>(ud)->append(ptr, size * nmemb); return size * nmemb;}
class Client {public: Client() : curl_(curl_easy_init()) { if (!curl_) throw std::runtime_error("curl_easy_init failed"); curl_easy_setopt(curl_, CURLOPT_TCP_KEEPALIVE, 1L); curl_easy_setopt(curl_, CURLOPT_TCP_KEEPIDLE, 30L); curl_easy_setopt(curl_, CURLOPT_WRITEFUNCTION, write_cb); } ~Client() { if (curl_) curl_easy_cleanup(curl_); } Client(const Client&) = delete; Client& operator=(const Client&) = delete;
std::string ocr_raw(const std::string& path) { std::ifstream f(path, std::ios::binary); std::ostringstream ss; ss << f.rdbuf(); std::string body = ss.str();
std::string response; curl_slist* hdrs = curl_slist_append(nullptr, "Content-Type: image/png");
curl_easy_setopt(curl_, CURLOPT_URL, "http://localhost:8000/ocr/raw"); curl_easy_setopt(curl_, CURLOPT_POST, 1L); curl_easy_setopt(curl_, CURLOPT_HTTPHEADER, hdrs); curl_easy_setopt(curl_, CURLOPT_POSTFIELDS, body.data()); curl_easy_setopt(curl_, CURLOPT_POSTFIELDSIZE, (long)body.size()); curl_easy_setopt(curl_, CURLOPT_WRITEDATA, &response); curl_easy_setopt(curl_, CURLOPT_TIMEOUT, 30L);
CURLcode rc = curl_easy_perform(curl_); curl_slist_free_all(hdrs); if (rc != CURLE_OK) throw std::runtime_error(curl_easy_strerror(rc)); return response; }
private: CURL* curl_ = nullptr;};
int main() { Client c; std::printf("%s\n", c.ocr_raw("invoice.png").c_str());}并发分发时,每个线程一个 handle,由 std::async 驱动。如果是单线程事件循环,curl_multi_* 可以在一个线程上同时驱动多个传输。
#include <future>#include <string>#include <vector>
std::vector<std::string> ocr_many(const std::vector<std::string>& paths) { std::vector<std::future<std::string>> futures; for (const auto& p : paths) { futures.push_back(std::async(std::launch::async, [p] { thread_local Client c; // one handle per worker thread return c.ocr_raw(p); })); } std::vector<std::string> out; out.reserve(futures.size()); for (auto& f : futures) out.push_back(f.get()); return out;}进阶 —— gRPC
如果你需要流式传输、在极高 QPS 下进一步压缩线缆开销,或者你的服务体系本身就是 protobuf,再考虑 gRPC。绝大多数场景下 HTTP 更简单,吞吐也旗鼓相当。服务定义在 ocr.proto 里 —— 下载后用对应语言的代码生成器跑一遍即可。
Python (gRPC)
pip install "grpcio>=1.68" "grpcio-tools>=1.68" "protobuf>=5.28"python -m grpc_tools.protoc -I proto \ --python_out=. --grpc_python_out=. \ proto/ocr.proto会生成 ocr_pb2.py 和 ocr_pb2_grpc.py。整个进程复用同一个 channel,它内部会复用 HTTP/2 流。
import grpcfrom concurrent.futures import ThreadPoolExecutorimport ocr_pb2import ocr_pb2_grpc
CHANNEL = grpc.insecure_channel( "localhost:50051", options=[ ("grpc.keepalive_time_ms", 30_000), ("grpc.max_receive_message_length", 50 * 1024 * 1024), ],)STUB = ocr_pb2_grpc.OCRServiceStub(CHANNEL)
def recognize(path: str) -> ocr_pb2.OCRResponse: with open(path, "rb") as f: return STUB.Recognize( ocr_pb2.OCRRequest(image=f.read(), layout=False), timeout=30, )
def recognize_many(paths: list[str], workers: int = 8): with ThreadPoolExecutor(max_workers=workers) as pool: return list(pool.map(recognize, paths))
results = recognize_many(["a.png", "b.png", "c.png"])Java (gRPC)
把 ocr.proto 放在 src/main/proto/ 下,由 protobuf Gradle 插件在每次构建时生成代码。
# build.gradleplugins { id 'com.google.protobuf' version '0.9.4' }
dependencies { implementation 'io.grpc:grpc-netty-shaded:1.70.0' implementation 'io.grpc:grpc-protobuf:1.70.0' implementation 'io.grpc:grpc-stub:1.70.0' implementation 'com.google.protobuf:protobuf-java:4.28.3'}
protobuf { protoc { artifact = 'com.google.protobuf:protoc:4.28.3' } plugins { grpc { artifact = 'io.grpc:protoc-gen-grpc-java:1.70.0' } } generateProtoTasks { all().each { task -> task.plugins { grpc {} } } }}整个进程一个 ManagedChannel,跨 stub、跨线程共用。
import com.google.protobuf.ByteString;import io.grpc.ManagedChannel;import io.grpc.ManagedChannelBuilder;import ocr.Ocr.OCRRequest;import ocr.Ocr.OCRResponse;import ocr.OCRServiceGrpc;
import java.nio.file.Files;import java.nio.file.Path;import java.util.List;import java.util.concurrent.Executors;import java.util.concurrent.Future;import java.util.concurrent.TimeUnit;
public class GrpcFanOut { public static void main(String[] args) throws Exception { ManagedChannel channel = ManagedChannelBuilder .forAddress("localhost", 50051) .usePlaintext() .keepAliveTime(30, TimeUnit.SECONDS) .maxInboundMessageSize(50 * 1024 * 1024) .build(); var stub = OCRServiceGrpc.newBlockingStub(channel);
var paths = List.of(Path.of("a.png"), Path.of("b.png"), Path.of("c.png")); try (var pool = Executors.newVirtualThreadPerTaskExecutor()) { List<Future<OCRResponse>> futures = paths.stream() .map(p -> pool.submit(() -> { var req = OCRRequest.newBuilder() .setImage(ByteString.copyFrom(Files.readAllBytes(p))) .setLayout(false) .build(); return stub.recognize(req); })) .toList(); for (var f : futures) System.out.println(f.get().getNumDetections()); } channel.shutdown().awaitTermination(5, TimeUnit.SECONDS); }}C++ (gRPC)
apt install protobuf-compiler-grpc libgrpc++-devprotoc -I proto \ --cpp_out=. --grpc_out=. \ --plugin=protoc-gen-grpc=$(which grpc_cpp_plugin) \ proto/ocr.proto编译时加 g++ -std=c++20 ... -lgrpc++ -lprotobuf。整个进程复用同一个 std::shared_ptr<grpc::Channel>。
// Compile: g++ -std=c++20 grpc_client.cc ocr.pb.cc ocr.grpc.pb.cc -lgrpc++ -lprotobuf -lpthread#include <grpcpp/grpcpp.h>#include "ocr.grpc.pb.h"#include <chrono>#include <fstream>#include <future>#include <sstream>#include <vector>
static std::string slurp(const std::string& p) { std::ifstream f(p, std::ios::binary); std::ostringstream ss; ss << f.rdbuf(); return ss.str();}
int main() { grpc::ChannelArguments args; args.SetInt(GRPC_ARG_KEEPALIVE_TIME_MS, 30'000); args.SetInt(GRPC_ARG_MAX_RECEIVE_MESSAGE_LENGTH, 50 * 1024 * 1024); auto channel = grpc::CreateCustomChannel( "localhost:50051", grpc::InsecureChannelCredentials(), args); auto stub = ocr::OCRService::NewStub(channel);
auto recognize = [&](const std::string& path) { ocr::OCRRequest req; req.set_image(slurp(path)); ocr::OCRResponse resp; grpc::ClientContext ctx; ctx.set_deadline(std::chrono::system_clock::now() + std::chrono::seconds(30)); stub->Recognize(&ctx, req, &resp); return resp.num_detections(); };
std::vector<std::string> paths = {"a.png", "b.png", "c.png"}; std::vector<std::future<int>> futures; for (const auto& p : paths) { futures.push_back(std::async(std::launch::async, recognize, p)); } for (auto& f : futures) std::printf("%d\n", f.get());}每个 endpoint 的请求与响应细节,请见 API 参考。