Skip to main content

dtmrs_server/grpc/
client.rs

1//! TC 去调业务方的 gRPC 分支。
2//!
3//! # 为什么不需要业务方的 proto
4//!
5//! 这是这一层唯一的技术难点。TC 要能调**任意**业务服务的**任意**方法,
6//! 但编译期根本不知道对方的 proto 长什么样。
7//!
8//! 解法是绕开 protobuf 的类型系统:用一个只会搬字节的 [`BytesCodec`] 替换
9//! tonic 默认的 prost codec,请求体发裸字节、响应体收裸字节。gRPC 的方法路径
10//! (`/包.服务/方法`)本来就是运行期的字符串,所以整条调用链都不需要类型信息。
11//!
12//! 换来的好处很实在:**业务方不用为 dtmrs 改接口**,已有的 gRPC 服务直接
13//! 就能当分支用。DTM 也是这个路子。
14//!
15//! # 请求体发什么
16//!
17//! 空字节。空的 protobuf 消息对**任何** message 类型都是合法的(所有字段取默认值),
18//! 所以不管对方方法的入参声明成什么都能解开。
19//!
20//! 分支的身份(gid / branch_id / op / trans_type)走 metadata,不走请求体 ——
21//! 这正是屏障需要的全部信息。跟 HTTP 那边把它们放 query 参数是一回事。
22//!
23//! (每步独立的业务 payload 是后续版本的事,HTTP 那边目前也统一发 `{}`。)
24//!
25//! # 结果判定
26//!
27//! 只看 gRPC 状态码,映射见 [`dtmrs_core::BranchResult::from_grpc`]。
28//! **连不上、超时、`UNAVAILABLE` 一律是「结果未知」而不是失败** ——
29//! 跟 HTTP 侧「超时不等于失败」是同一条命门。
30
31// 走 prost 的 re-export,不额外引一个 bytes 依赖
32use dtmrs_core::BranchResult;
33use prost::bytes::{Buf, BufMut};
34use std::collections::HashMap;
35use std::sync::{Arc, Mutex};
36use std::time::Duration;
37use tonic::codec::{Codec, DecodeBuf, Decoder, EncodeBuf, Encoder};
38use tonic::codegen::http::uri::PathAndQuery;
39use tonic::transport::{Channel, Endpoint};
40use tonic::Status;
41use tracing::{info, warn};
42
43use super::{MD_BRANCH_ID, MD_GID, MD_OP, MD_TRANS_TYPE};
44use crate::registry::GrpcTarget;
45
46/// 只搬字节的 codec —— 让 tonic 在不知道消息类型的前提下完成一次 unary 调用。
47#[derive(Debug, Default, Clone, Copy)]
48pub struct BytesCodec;
49
50impl Codec for BytesCodec {
51    type Encode = Vec<u8>;
52    type Decode = Vec<u8>;
53    type Encoder = BytesCodec;
54    type Decoder = BytesCodec;
55
56    fn encoder(&mut self) -> Self::Encoder {
57        *self
58    }
59    fn decoder(&mut self) -> Self::Decoder {
60        *self
61    }
62}
63
64impl Encoder for BytesCodec {
65    type Item = Vec<u8>;
66    type Error = Status;
67
68    fn encode(&mut self, item: Self::Item, dst: &mut EncodeBuf<'_>) -> Result<(), Self::Error> {
69        dst.put_slice(&item);
70        Ok(())
71    }
72}
73
74impl Decoder for BytesCodec {
75    type Item = Vec<u8>;
76    type Error = Status;
77
78    /// tonic 保证 `src` 里正好是一条完整消息,不用自己拆帧
79    fn decode(&mut self, src: &mut DecodeBuf<'_>) -> Result<Option<Self::Item>, Self::Error> {
80        let mut out = vec![0u8; src.remaining()];
81        src.copy_to_slice(&mut out);
82        Ok(Some(out))
83    }
84}
85
86/// gRPC 分支调用器,带 channel 缓存。
87///
88/// 用 `connect_lazy` 而不是 `connect`:连接在首次真正发请求时才建立,
89/// 断了之后 tonic 自己重连。所以缓存里的 channel **不会因为对方重启而变成死的**,
90/// 不需要额外的健康检查和淘汰逻辑。
91#[derive(Clone)]
92pub struct GrpcCaller {
93    channels: Arc<Mutex<HashMap<String, Channel>>>,
94    timeout: Duration,
95    /// 额外信任的 CA(PEM 内容,不是路径)。内网自签证书用。
96    ///
97    /// 做成字段而不是每次去读环境变量,是为了能测:改进程级 env 在并行测试里
98    /// 会互相打架,而 TLS 这种东西不实际连一次根本不知道配没配对。
99    extra_ca: Option<Arc<Vec<u8>>>,
100}
101
102/// 检查这份 PEM 里到底有没有证书,没有就**大声拒掉**。
103///
104/// ⚠ 这个函数存在的理由是一次实测:给 tonic 传一份垃圾 PEM,
105/// `tls_config()` **不报任何错** —— rustls 的 `add_parsable_certificates`
106/// 会静默跳过认不出的条目。于是运维把 `DTMRS_GRPC_CA` 指错文件(指到了私钥、
107/// 指到了不存在的软链、文件被截断)时没有任何提示,只会在握手时收到一个
108/// 跟根因毫无关系的错误。
109///
110/// 这里只做最轻的判断(有没有 BEGIN CERTIFICATE 块),不引入 PEM 解析器 ——
111/// 要抓的就是「指错文件」这类现实错误,不是要做完整校验。
112fn check_ca_pem(pem: Vec<u8>, from: &str) -> Option<Arc<Vec<u8>>> {
113    const MARK: &[u8] = b"-----BEGIN CERTIFICATE-----";
114    let has_cert = pem.windows(MARK.len()).any(|w| w == MARK);
115    if !has_cert {
116        warn!(
117            source = from,
118            bytes = pem.len(),
119            "额外 CA 里没有 BEGIN CERTIFICATE 块,已忽略 —— \
120             传了私钥或指错文件?注意 tonic 对这种输入不会报错,只会静默不生效"
121        );
122        return None;
123    }
124    Some(Arc::new(pem))
125}
126
127impl GrpcCaller {
128    /// `DTMRS_GRPC_CA` 指向一个 PEM 文件时,把它加进信任列表。
129    ///
130    /// 读不到就**只是不加**,不 panic —— 推进器是常驻的,
131    /// 因为一个可选配置起不来比连不上更糟。
132    pub fn new(timeout: Duration) -> Self {
133        let extra_ca = std::env::var("DTMRS_GRPC_CA").ok().and_then(|p| {
134            match std::fs::read(&p) {
135                Ok(pem) => check_ca_pem(pem, &p),
136                Err(e) => {
137                    warn!(path = %p, error = %e, "DTMRS_GRPC_CA 读不到,忽略该配置");
138                    None
139                }
140            }
141        });
142        Self {
143            channels: Arc::new(Mutex::new(HashMap::new())),
144            timeout,
145            extra_ca,
146        }
147    }
148
149    /// 直接指定额外信任的 CA(PEM 内容)。测试和嵌入式宿主用,绕开环境变量。
150    pub fn with_ca_pem(mut self, pem: impl Into<Vec<u8>>) -> Self {
151        self.extra_ca = check_ca_pem(pem.into(), "<直接传入>");
152        self
153    }
154
155    fn channel(&self, target: &GrpcTarget) -> Result<Channel, String> {
156        let endpoint = target.endpoint.as_str();
157        // 先查缓存。锁里不做 await,所以用 std 的 Mutex 就够
158        if let Some(c) = self.channels.lock().unwrap().get(endpoint) {
159            return Ok(c.clone());
160        }
161        let mut ep = Endpoint::from_shared(endpoint.to_string())
162            .map_err(|e| format!("gRPC 地址不合法: {e}"))?
163            .timeout(self.timeout)
164            .connect_timeout(self.timeout);
165
166        if target.tls {
167            // with_enabled_roots():把编译进来的根证书集合都启用
168            // (native = 系统信任库,走内网自签 CA;webpki = 内置 Mozilla 根)。
169            // 域名不用手写,tonic 从 uri 的 host 里取,跟证书的 SAN 校验。
170            let mut tls = tonic::transport::ClientTlsConfig::new().with_enabled_roots();
171            if let Some(pem) = &self.extra_ca {
172                tls = tls.ca_certificate(tonic::transport::Certificate::from_pem(pem.as_slice()));
173            }
174            ep = ep
175                .tls_config(tls)
176                .map_err(|e| format!("gRPC TLS 配置不可用: {e}"))?;
177        }
178
179        let ch = ep.connect_lazy();
180        self.channels
181            .lock()
182            .unwrap()
183            .insert(endpoint.to_string(), ch.clone());
184        Ok(ch)
185    }
186
187    /// 调一个 gRPC 分支。任何失败都不会返回 [`BranchResult::Failure`] ——
188    /// 只有对方**明确**返回 `ABORTED` 才算业务要求回滚。
189    pub async fn call(
190        &self,
191        target: &GrpcTarget,
192        gid: &str,
193        trans_type: &str,
194        branch_id: &str,
195        op: &str,
196    ) -> BranchResult {
197        let channel = match self.channel(target) {
198            Ok(c) => c,
199            Err(e) => {
200                // 地址都拼不出来,是配置错误。但仍然按「未知」处理:
201                // 判失败会触发回滚,而这其实是部署问题,改对了重试才对
202                warn!(gid, branch = branch_id, endpoint = %target.endpoint, error = %e,
203                      "gRPC 分支地址不合法,按结果未知处理(会重试,不回滚)");
204                return BranchResult::Unknown;
205            }
206        };
207
208        let path = match PathAndQuery::try_from(target.path.clone()) {
209            Ok(p) => p,
210            Err(e) => {
211                warn!(gid, branch = branch_id, path = %target.path, error = %e,
212                      "gRPC 方法路径不合法,按结果未知处理");
213                return BranchResult::Unknown;
214            }
215        };
216
217        let mut grpc = tonic::client::Grpc::new(channel);
218        if let Err(e) = grpc.ready().await {
219            warn!(gid, branch = branch_id, error = %e, "gRPC 分支不可达,结果未知");
220            return BranchResult::Unknown;
221        }
222
223        // 空消息体:对任何 message 类型都合法。分支身份走 metadata
224        let mut req = tonic::Request::new(Vec::<u8>::new());
225        for (k, v) in [
226            (MD_GID, gid),
227            (MD_TRANS_TYPE, trans_type),
228            (MD_BRANCH_ID, branch_id),
229            (MD_OP, op),
230        ] {
231            match v.parse() {
232                Ok(val) => {
233                    req.metadata_mut().insert(k, val);
234                }
235                Err(_) => {
236                    // gid 里有非 ASCII 之类。这些值是我们自己生成/客户端给的,
237                    // 塞不进 header 就没法让业务方做幂等 —— 宁可不调
238                    warn!(
239                        gid,
240                        branch = branch_id,
241                        key = k,
242                        "metadata 值不合法(非 ASCII?),无法调用 gRPC 分支"
243                    );
244                    return BranchResult::Unknown;
245                }
246            }
247        }
248
249        match grpc
250            .unary::<Vec<u8>, Vec<u8>, BytesCodec>(req, path, BytesCodec)
251            .await
252        {
253            Ok(_) => {
254                info!(gid, branch = branch_id, op, "gRPC 分支返回 OK");
255                BranchResult::Success
256            }
257            Err(status) => {
258                let r = BranchResult::from_grpc(status.code() as i32);
259                info!(gid, branch = branch_id, op, code = ?status.code(), result = ?r,
260                      "gRPC 分支返回");
261                r
262            }
263        }
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    fn target(endpoint: &str, tls: bool) -> GrpcTarget {
272        GrpcTarget {
273            endpoint: endpoint.into(),
274            path: "/a.B/C".into(),
275            tls,
276        }
277    }
278
279    #[test]
280    fn 地址不合法时是未知而不是失败() {
281        // 判失败会触发回滚,而地址写错是部署问题
282        let c = GrpcCaller::new(Duration::from_secs(1));
283        assert!(c.channel(&target("这不是个地址", false)).is_err());
284    }
285
286    #[tokio::test]
287    async fn 连不上的分支不能触发回滚() {
288        // 端口上没人听 —— 必须是 Unknown(重试),绝不能是 Failure(回滚)
289        let c = GrpcCaller::new(Duration::from_millis(300));
290        let r = c
291            .call(&target("http://127.0.0.1:1", false), "g1", "saga", "01", "action")
292            .await;
293        assert_eq!(r, BranchResult::Unknown, "连不上必须是未知,不能是失败");
294    }
295
296    /// TLS 握手失败(对面根本不是 TLS 服务)也必须是 Unknown。
297    ///
298    /// ⚠ 这条容易想当然:证书错误感觉像「明确的拒绝」,但它跟业务无关 ——
299    /// 判成 Failure 会因为一个配置问题去回滚一笔可能已经成功的事务。
300    #[tokio::test]
301    async fn tls握手失败也只能是未知() {
302        let c = GrpcCaller::new(Duration::from_millis(300));
303        let r = c
304            .call(&target("https://127.0.0.1:1", true), "g1", "saga", "01", "action")
305            .await;
306        assert_eq!(r, BranchResult::Unknown, "TLS 失败是部署问题,不是业务拒绝");
307    }
308
309    #[tokio::test]
310    async fn tls端点能建出channel() {
311        // connect_lazy 不会真握手,这里验的是 tls_config 本身能装上 ——
312        // 根证书集合没编进来的话这一步就会报错
313        let c = GrpcCaller::new(Duration::from_secs(1));
314        assert!(
315            c.channel(&target("https://busi.internal:9000", true)).is_ok(),
316            "TLS 配置装不上,多半是 tonic 的 tls feature 没开"
317        );
318    }
319
320    /// ⚠ 实测发现的坑:给 tonic 传垃圾 PEM,`tls_config()` **不报错** ——
321    /// rustls 会静默跳过认不出的条目。所以指错文件时完全没有提示,
322    /// 只会在握手阶段收到一个跟根因无关的错误。
323    ///
324    /// 现在在装载时就挡掉并打警告。这里断言的是「没被当成 CA 收下」,
325    /// 而不是「channel 建不出来」—— 建得出来是对的,只是不该多信任什么。
326    #[tokio::test]
327    async fn 垃圾ca要被挡掉而不是静默收下() {
328        for junk in [
329            &b"not a pem"[..],
330            &b"-----BEGIN PRIVATE KEY-----\nxxx\n-----END PRIVATE KEY-----"[..], // 指到私钥
331            &b""[..],
332        ] {
333            let c = GrpcCaller::new(Duration::from_secs(1)).with_ca_pem(junk.to_vec());
334            assert!(
335                c.extra_ca.is_none(),
336                "垃圾 PEM 被当成 CA 收下了,那它只会静默不生效"
337            );
338            // 而且不能把推进器搞崩
339            assert!(c.channel(&target("https://a:1", true)).is_ok());
340        }
341    }
342
343    #[tokio::test]
344    async fn 像样的ca要能装上() {
345        let pem = b"-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n".to_vec();
346        let c = GrpcCaller::new(Duration::from_secs(1)).with_ca_pem(pem);
347        assert!(c.extra_ca.is_some(), "守卫不能误伤真的证书");
348    }
349
350    /// `connect_lazy` 内部要拿 tokio 的 executor,**必须在运行时里调** ——
351    /// 普通 `#[test]` 会直接 panic。生产路径上分支调用本来就在运行时里,
352    /// 不受影响
353    #[tokio::test]
354    async fn channel会被缓存复用() {
355        let c = GrpcCaller::new(Duration::from_secs(1));
356        let a = c.channel(&target("http://127.0.0.1:9", false)).unwrap();
357        let b = c.channel(&target("http://127.0.0.1:9", false)).unwrap();
358        // 缓存命中时表里只该有一条
359        assert_eq!(c.channels.lock().unwrap().len(), 1);
360        drop((a, b));
361    }
362}