diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 8dcf336e..27be4553 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -15,6 +15,10 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Install protoc + run: | + sudo apt-get update + sudo apt-get install -y protobuf-compiler - name: Install dependency run: cargo install cargo-workspaces - name: Build @@ -22,4 +26,8 @@ jobs: - name: Run tests run: cargo test --workspace --verbose - name: Run Publish - run: sed -i 's/"examples.*,//' Cargo.toml && cargo ws publish --publish-as-is --token=${{secrets.CARGO_TOKEN}} --allow-dirty + run: | + sed -i 's/"examples.*,//' Cargo.toml && + cp README.md ./spring/ && + sed -i 's|include_str!("../../README.md")|include_str!("../README.md")|' spring/src/lib.rs + cargo ws publish --publish-as-is --token=${{secrets.CARGO_TOKEN}} --allow-dirty diff --git a/.gitignore b/.gitignore index 682b9034..6b2d6704 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,10 @@ rust-project.json !.vscode/extensions.json !.vscode/*.code-snippets +### IDEA RustRover ### +.idea/ +*.iml + docs/public ### flamegraph diff --git a/.vscode/launch.json b/.vscode/launch.json index c8d687f2..f0e3bf3e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -370,6 +370,27 @@ }, "cwd": "${workspaceFolder}/examples/opentelemetry-example" }, + { + "type": "lldb", + "request": "launch", + "name": "Debug executable 'hello-world-example'", + "cargo": { + "args": [ + "build", + "--bin=hello-world-example", + "--package=hello-world-example" + ], + "filter": { + "name": "hello-world-example", + "kind": "bin" + } + }, + "args": [], + "env": { + "RUST_BACKTRACE": "1", + }, + "cwd": "${workspaceFolder}/examples/hello-world-example" + }, { "type": "lldb", "request": "launch", diff --git a/Cargo.toml b/Cargo.toml index 9fd81765..05f4a36a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "spring", "spring-macros", "spring-web", + "spring-grpc", "spring-job", "spring-redis", "spring-sqlx", @@ -18,35 +19,36 @@ default-members = ["spring", "spring-*"] exclude = ["examples/**"] [workspace.package] -version = "0.2.0" +version = "0.4.0" edition = "2021" license = "MIT" authors = ["holmofy"] repository = "https://github.com/spring-rs/spring-rs" [workspace.dependencies] -thiserror = "1.0" +thiserror = "2.0" anyhow = "1.0" serde = "1.0" serde_json = "1.0" -tokio = "1.39" +tokio = "1.44" log = "0.4" -tracing = "0.1.40" -tracing-subscriber = "0.3.18" +tracing = "0.1" +tracing-subscriber = "0.3" tracing-appender = "0.2" -nu-ansi-term = "0.46" +tracing-error = "0.2" +nu-ansi-term = "0.50" tower = "0.5" tower-http = "0.6" tower-layer = "0.3" tower-service = "0.3" futures-util = "0.3" byte-unit = "5.1" -axum = "0.7" +axum = "0.8" sqlx = "0.8" sea-orm = "1.1" sea-streamer = "0.5" tokio-postgres = "0.7" -redis = "0.27" +redis = "0.29" lettre = "0.11" tokio-cron-scheduler = "0.13" inventory = "0.3.15" @@ -61,18 +63,20 @@ schemars = "0.8.21" dashmap = "6.1" uuid = "1" chrono = "0.4" -opentelemetry = "0.26" -opentelemetry_sdk = "0.26" -opentelemetry-otlp = "0.26" -opentelemetry-http = "0.26" -opentelemetry-appender-tracing = "0.26" -opentelemetry-jaeger-propagator = "0.26" -opentelemetry-semantic-conventions = "0.26" -opentelemetry-prometheus = "0.17" -opentelemetry-zipkin = "0.26" -opentelemetry-resource-detectors = "0.5" -tracing-opentelemetry = "0.27" +opentelemetry = "0.29" +opentelemetry_sdk = "0.29" +opentelemetry-otlp = "0.29" +opentelemetry-http = "0.29" +opentelemetry-appender-tracing = "0.29" +opentelemetry-jaeger-propagator = "0.29" +opentelemetry-semantic-conventions = "0.29" +opentelemetry-prometheus = "0.29" +opentelemetry-zipkin = "0.29" +opentelemetry-resource-detectors = "0.8" +tracing-opentelemetry = "0.30" pin-project = "1" -tonic = "0.12" +tonic = "0.13" +prost = "0.13" http = "1" -once_cell = "1" +http-body = "1" +once_cell = "1" \ No newline at end of file diff --git a/README.md b/README.md index c57dd5c4..50e1ea7b 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Compared with SpringBoot in java, spring-rs has higher performance and lower mem **web** -```rust +```rust,no_run use spring::{auto_config, App}; use spring_sqlx::{ sqlx::{self, Row}, @@ -58,7 +58,7 @@ async fn hello_world() -> impl IntoResponse { "hello world" } -#[route("/hello/:name", method = "GET", method = "POST")] +#[route("/hello/{name}", method = "GET", method = "POST")] async fn hello(Path(name): Path) -> impl IntoResponse { format!("hello {name}") } @@ -76,7 +76,7 @@ async fn sqlx_request_handler(Component(pool): Component) -> Result **job** -```rust +```rust,no_run use anyhow::Context; use spring::{auto_config, App}; use spring_job::{cron, fix_delay, fix_rate}; @@ -139,13 +139,15 @@ async fn fix_rate_job() { * [x] ![spring-job](https://img.shields.io/crates/v/spring-job.svg)[`spring-job`](https://spring-rs.github.io/docs/plugins/spring-job/): Integrated with [`tokio-cron-scheduler`](https://github.com/mvniekerk/tokio-cron-scheduler) * [x] ![spring-stream](https://img.shields.io/crates/v/spring-stream.svg)[`spring-stream`](https://spring-rs.github.io/docs/plugins/spring-stream/): Integrate [`sea-streamer`](https://github.com/SeaQL/sea-streamer) to implement message processing such as redis-stream and kafka * [x] ![spring-opentelemetry](https://img.shields.io/crates/v/spring-opentelemetry.svg)[`spring-opentelemetry`](https://spring-rs.github.io/docs/plugins/spring-opentelemetry/): Integrate with [`opentelemetry`](https://github.com/open-telemetry/opentelemetry-rust) to implement full observability of logging, metrics, tracing -* [ ] `spring-tarpc`: Integrate[`tarpc`](https://github.com/google/tarpc) to implement RPC calls +* [x] ![spring-grpc](https://img.shields.io/crates/v/spring-grpc.svg)[`spring-grpc`](https://spring-rs.github.io/docs/plugins/spring-grpc/): Integrate[`tonic`](https://github.com/hyperium/tonic) to implement gRPC calls ## Ecosystem * ![spring-sqlx-migration-plugin](https://img.shields.io/crates/v/spring-sqlx-migration-plugin.svg) [`spring-sqlx-migration-plugin`](https://github.com/Phosphorus-M/spring-sqlx-migration-plugin) * ![spring-opendal](https://img.shields.io/crates/v/spring-opendal.svg) [`spring-opendal`](https://github.com/spring-rs/contrib-plugins/tree/master/spring-opendal) +[more>>](https://crates.io/crates/spring/reverse_dependencies) + ## Project showcase * [Raline](https://github.com/ralinejs/raline) diff --git a/README.zh.md b/README.zh.md index 55f04f31..c9812ffe 100644 --- a/README.zh.md +++ b/README.zh.md @@ -57,7 +57,7 @@ async fn hello_world() -> impl IntoResponse { "hello world" } -#[route("/hello/:name", method = "GET", method = "POST")] +#[route("/hello/{name}", method = "GET", method = "POST")] async fn hello(Path(name): Path) -> impl IntoResponse { format!("hello {name}") } @@ -137,14 +137,15 @@ async fn fix_rate_job() { * [x] ![spring-mail](https://img.shields.io/crates/v/spring-mail.svg)[`spring-mail`](./spring-mail/README.zh.md)(整合了[`lettre`](https://github.com/lettre/lettre)) * [x] ![spring-job](https://img.shields.io/crates/v/spring-job.svg)[`spring-job`](./spring-job/README.zh.md)(整合了[`tokio-cron-scheduler`](https://github.com/mvniekerk/tokio-cron-scheduler)) * [x] ![spring-stream](https://img.shields.io/crates/v/spring-stream.svg)[`spring-stream`](./spring-stream/README.zh.md)(整合了[`sea-streamer`](https://github.com/SeaQL/sea-streamer)实现redis-stream、kafka等消息处理) -* [x] ![spring-opentelemetry](https://img.shields.io/crates/v/spring-opentelemetry.svg)[`spring-opentelemetry`]((./spring-opentelemetry/README.zh.md))(整合了[`opentelemetry`](https://github.com/open-telemetry/opentelemetry-rust)实现logging、metrics、tracing全套可观测性) -* [ ] `spring-tarpc`(整合了[`tarpc`](https://github.com/google/tarpc)实现RPC调用) +* [x] ![spring-opentelemetry](https://img.shields.io/crates/v/spring-opentelemetry.svg)[`spring-opentelemetry`](./spring-opentelemetry/README.zh.md)(整合了[`opentelemetry`](https://github.com/open-telemetry/opentelemetry-rust)实现logging、metrics、tracing全套可观测性) +* [x] ![spring-grpc](https://img.shields.io/crates/v/spring-grpc.svg)[`spring-grpc`](./spring-grpc/README.zh.md)(整合了[`tonic`](https://github.com/hyperium/tonic)实现gRPC调用) ## 生态 * ![spring-sqlx-migration-plugin](https://img.shields.io/crates/v/spring-sqlx-migration-plugin.svg) [`spring-sqlx-migration-plugin`](https://github.com/Phosphorus-M/spring-sqlx-migration-plugin) * ![spring-opendal](https://img.shields.io/crates/v/spring-opendal.svg) [`spring-opendal`](https://github.com/spring-rs/contrib-plugins/tree/master/spring-opendal) +[更多>>](https://crates.io/crates/spring/reverse_dependencies) star history diff --git a/docs/content/_index.md b/docs/content/_index.md index 21796066..b4146af1 100644 --- a/docs/content/_index.md +++ b/docs/content/_index.md @@ -7,7 +7,7 @@ title = "spring-rs" lead = "spring-rs is a application framework written in rust inspired by java's spring-boot" url = "/docs/getting-started/introduction/" url_button = "Get started" -repo_version = "GitHub v0.3.x" +repo_version = "GitHub v0.4.x" repo_license = "Open-source MIT License." repo_url = "https://github.com/spring-rs/spring-rs" logo = "logo.svg" diff --git a/docs/content/_index.zh.md b/docs/content/_index.zh.md index 0ade9949..2eedba19 100644 --- a/docs/content/_index.zh.md +++ b/docs/content/_index.zh.md @@ -7,7 +7,7 @@ title = "spring-rs" lead = 'spring-rs是Rust编写的应用框架,与java生态的spring-boot类似' url = "/zh/docs/getting-started/introduction/" url_button = "快速上手" -repo_version = "GitHub v0.3.x" +repo_version = "GitHub v0.4.x" repo_license = "Open-source MIT License." repo_url = "https://github.com/spring-rs/spring-rs" logo = "logo.svg" diff --git a/docs/content/blog/spring-rs-initial-version.md b/docs/content/blog/spring-rs-initial-version.md index 0f0421ca..a205717b 100644 --- a/docs/content/blog/spring-rs-initial-version.md +++ b/docs/content/blog/spring-rs-initial-version.md @@ -31,7 +31,7 @@ async fn hello_world() -> impl IntoResponse { "hello world" } -#[route("/hello/:name", method = "GET", method = "POST")] +#[route("/hello/{name}", method = "GET", method = "POST")] async fn hello(Path(name): Path) -> impl IntoResponse { format!("hello {name}") } diff --git a/docs/content/blog/spring-rs-initial-version.zh.md b/docs/content/blog/spring-rs-initial-version.zh.md index 2632f463..0c9daaa0 100644 --- a/docs/content/blog/spring-rs-initial-version.zh.md +++ b/docs/content/blog/spring-rs-initial-version.zh.md @@ -31,7 +31,7 @@ async fn hello_world() -> impl IntoResponse { "hello world" } -#[route("/hello/:name", method = "GET", method = "POST")] +#[route("/hello/{name}", method = "GET", method = "POST")] async fn hello(Path(name): Path) -> impl IntoResponse { format!("hello {name}") } diff --git a/docs/content/docs/getting-started/quick-start.zh.md b/docs/content/docs/getting-started/quick-start.zh.md index fcbb882a..086e26f4 100644 --- a/docs/content/docs/getting-started/quick-start.zh.md +++ b/docs/content/docs/getting-started/quick-start.zh.md @@ -67,7 +67,7 @@ async fn hello_world() -> impl IntoResponse { } // 也可以使用route宏指定Http Method和请求路径。Path从HTTP请求中提取请求路径中的参数 -#[route("/hello/:name", method = "GET", method = "POST")] +#[route("/hello/{name}", method = "GET", method = "POST")] async fn hello(Path(name): Path) -> impl IntoResponse { format!("hello {name}") } diff --git a/docs/content/docs/plugins/plugin-by-self.md b/docs/content/docs/plugins/plugin-by-self.md index a3e35cb9..c1f02823 100644 --- a/docs/content/docs/plugins/plugin-by-self.md +++ b/docs/content/docs/plugins/plugin-by-self.md @@ -12,4 +12,4 @@ toc = true top = false +++ -{{ include(path="../../spring/README.md") }} \ No newline at end of file +{{ include(path="../../spring/Plugin.md") }} \ No newline at end of file diff --git a/docs/content/docs/plugins/plugin-by-self.zh.md b/docs/content/docs/plugins/plugin-by-self.zh.md index f5e592bd..5e3d8925 100644 --- a/docs/content/docs/plugins/plugin-by-self.zh.md +++ b/docs/content/docs/plugins/plugin-by-self.zh.md @@ -12,4 +12,4 @@ toc = true top = false +++ -{{ include(path="../../spring/README.zh.md") }} \ No newline at end of file +{{ include(path="../../spring/Plugin.zh.md") }} \ No newline at end of file diff --git a/docs/content/docs/plugins/spring-grpc.md b/docs/content/docs/plugins/spring-grpc.md new file mode 100644 index 00000000..a6910ec7 --- /dev/null +++ b/docs/content/docs/plugins/spring-grpc.md @@ -0,0 +1,15 @@ ++++ +title = "spring-grpc Plugin" +description = "How to use the spring-grpc plugin" +draft = false +weight = 23 +sort_by = "weight" +template = "docs/page.html" + +[extra] +lead = "spring-grpc is based on tonic" +toc = true +top = false ++++ + +{{ include(path="../../spring-grpc/README.md") }} \ No newline at end of file diff --git a/docs/content/docs/plugins/spring-grpc.zh.md b/docs/content/docs/plugins/spring-grpc.zh.md new file mode 100644 index 00000000..9776154e --- /dev/null +++ b/docs/content/docs/plugins/spring-grpc.zh.md @@ -0,0 +1,15 @@ ++++ +title = "spring-grpc插件" +description = "grpc插件如何使用" +draft = false +weight = 23 +sort_by = "weight" +template = "docs/page.html" + +[extra] +lead = "spring-grpc是基于tonic实现的" +toc = true +top = false ++++ + +{{ include(path="../../spring-grpc/README.zh.md") }} \ No newline at end of file diff --git a/examples/dependency-inject-example/Cargo.toml b/examples/dependency-inject-example/Cargo.toml index 10da441e..f470b263 100644 --- a/examples/dependency-inject-example/Cargo.toml +++ b/examples/dependency-inject-example/Cargo.toml @@ -12,4 +12,5 @@ spring-sqlx = { path = "../../spring-sqlx", features = ["postgres"] } tokio = { workspace = true, features = ["full", "tracing"] } anyhow = { workspace = true } serde = { workspace = true } -serde_json = { workspace = true } \ No newline at end of file +serde_json = { workspace = true } +derive_more = { version = "1", features = ["deref"] } diff --git a/examples/dependency-inject-example/config/app.toml b/examples/dependency-inject-example/config/app.toml index 7e74f0b4..39baabc6 100644 --- a/examples/dependency-inject-example/config/app.toml +++ b/examples/dependency-inject-example/config/app.toml @@ -4,3 +4,9 @@ project = "spring-rs" [sqlx] uri = "postgres://postgres:xudjf23adj213@127.0.0.1:5432" + +[logger] +pretty_backtrace = true + +[web] +graceful = true \ No newline at end of file diff --git a/examples/dependency-inject-example/src/main.rs b/examples/dependency-inject-example/src/main.rs index 17c687f4..3eeec4e7 100644 --- a/examples/dependency-inject-example/src/main.rs +++ b/examples/dependency-inject-example/src/main.rs @@ -1,9 +1,10 @@ use anyhow::Context; +use derive_more::derive::Deref; use serde::Deserialize; use spring::{ auto_config, config::{ConfigRef, Configurable}, - plugin::{component::ComponentRef, service::Service}, + plugin::{component::ComponentRef, service::Service, MutableComponentRegistry}, App, }; use spring_sqlx::{ @@ -19,6 +20,9 @@ use std::sync::{ Arc, }; +#[derive(Clone, Deref)] +struct PageView(Arc); + // Main function entry #[auto_config(WebConfigurator)] // auto config web router #[tokio::main] @@ -26,6 +30,7 @@ async fn main() { App::new() .add_plugin(SqlxPlugin) // Add plug-in .add_plugin(WebPlugin) + .add_component(PageView(Arc::new(AtomicI32::new(0)))) .run() .await } @@ -49,6 +54,22 @@ struct UserService { count: Arc, } +#[derive(Clone, Service)] +#[service(prototype)] // default builder fn is `build` +struct UserProtoService { + #[inject(component)] + count: PageView, + step: i32, +} + +#[derive(Service)] +#[service(prototype = "build")] +struct UserProtoServiceWithLifetime<'s> { + #[inject(component)] + count: PageView, + step: &'s i32, +} + impl UserService { pub async fn query_db(&self) -> Result { let UserConfig { @@ -110,6 +131,34 @@ impl UserServiceUseRef { } } +impl UserProtoService { + pub fn pv_count(&self) -> Result { + let Self { step, .. } = self; + + let pv_count = self.count.fetch_add(*step, Ordering::SeqCst); + + Ok(format!( + r#" + Page view counter is {pv_count} + "# + )) + } +} + +impl<'s> UserProtoServiceWithLifetime<'s> { + pub fn pv_count(&self) -> Result { + let Self { step, .. } = self; + + let pv_count = self.count.fetch_add(**step, Ordering::SeqCst); + + Ok(format!( + r#" + Page view counter is {pv_count} + "# + )) + } +} + #[get("/")] async fn hello(Component(user_service): Component) -> Result { Ok(user_service.query_db().await?) @@ -121,3 +170,15 @@ async fn hello_ref( ) -> Result { Ok(user_service.query_db().await?) } + +#[get("/prototype-service")] +async fn prototype_service() -> Result { + let service = UserProtoService::build(5).context("build service failed")?; + Ok(service.pv_count()?) +} + +#[get("/prototype-service-lifetime")] +async fn prototype_service_with_lifetime() -> Result { + let service = UserProtoServiceWithLifetime::build(&10).context("build service failed")?; + Ok(service.pv_count()?) +} diff --git a/examples/grpc-example/Cargo.toml b/examples/grpc-example/Cargo.toml new file mode 100644 index 00000000..d9b3b929 --- /dev/null +++ b/examples/grpc-example/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "grpc-example" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +spring = { path = "../../spring" } +spring-web = { path = "../../spring-web" } +spring-grpc = { path = "../../spring-grpc" } +prost = { workspace = true } +tonic = { workspace = true } +tokio = { workspace = true, features = ["full", "tracing"] } +anyhow = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[build-dependencies] +tonic-build = "0.13" diff --git a/examples/grpc-example/READMD.md b/examples/grpc-example/READMD.md new file mode 100644 index 00000000..19b26723 --- /dev/null +++ b/examples/grpc-example/READMD.md @@ -0,0 +1,17 @@ +## 1. run grpc server + +```sh +cargo run --bin server +``` + +## 2. run grpc client + +```sh +cargo run --bin client +``` + +## 3. access web + +```sh +curl localhost:8080 +``` \ No newline at end of file diff --git a/examples/grpc-example/build.rs b/examples/grpc-example/build.rs new file mode 100644 index 00000000..0c941bb2 --- /dev/null +++ b/examples/grpc-example/build.rs @@ -0,0 +1,4 @@ +fn main() -> Result<(), Box> { + tonic_build::compile_protos("proto/helloworld.proto")?; + Ok(()) +} \ No newline at end of file diff --git a/examples/grpc-example/config/app.toml b/examples/grpc-example/config/app.toml new file mode 100644 index 00000000..e69de29b diff --git a/examples/grpc-example/proto/helloworld.proto b/examples/grpc-example/proto/helloworld.proto new file mode 100644 index 00000000..13e203ea --- /dev/null +++ b/examples/grpc-example/proto/helloworld.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +package helloworld; + +// The greeting service definition. +service Greeter { + // Sends a greeting + rpc SayHello (HelloRequest) returns (HelloReply) {} +} + +// The request message containing the user's name. +message HelloRequest { + string name = 1; +} + +// The response message containing the greetings +message HelloReply { + string message = 1; +} \ No newline at end of file diff --git a/examples/grpc-example/src/bin/client.rs b/examples/grpc-example/src/bin/client.rs new file mode 100644 index 00000000..0ddaef5f --- /dev/null +++ b/examples/grpc-example/src/bin/client.rs @@ -0,0 +1,53 @@ +use hello_world::greeter_client::GreeterClient; +use hello_world::HelloRequest; +use spring::{auto_config, plugin::MutableComponentRegistry, App}; +use spring_web::{ + axum::response::IntoResponse, + extractor::{Component, Path}, + get, WebConfigurator, WebPlugin, +}; +use tonic::transport::Channel; + +pub mod hello_world { + tonic::include_proto!("helloworld"); +} + +#[auto_config(WebConfigurator)] +#[tokio::main] +async fn main() { + let client = GreeterClient::connect("http://127.0.0.1:9090") + .await + .expect("failed to connect server, please start server first"); + App::new() + .add_plugin(WebPlugin) + .add_component(client) + .run() + .await +} + +#[get("/")] +async fn hello_index( + Component(mut client): Component>, +) -> impl IntoResponse { + client + .say_hello(tonic::Request::new(HelloRequest { + name: "world".into(), + })) + .await + .expect("failed to say hello") + .into_inner() + .message +} + +#[get("/hello/{name}")] +async fn hello( + Path(name): Path, + Component(mut client): Component>, +) -> impl IntoResponse { + client + .say_hello(tonic::Request::new(HelloRequest { name })) + .await + .expect("failed to say hello") + .into_inner() + .message +} diff --git a/examples/grpc-example/src/bin/server.rs b/examples/grpc-example/src/bin/server.rs new file mode 100644 index 00000000..f0d1d950 --- /dev/null +++ b/examples/grpc-example/src/bin/server.rs @@ -0,0 +1,35 @@ +use spring::plugin::service::Service; +use spring::App; +use spring_grpc::GrpcPlugin; +use tonic::{Request, Response, Status}; + +use hello_world::greeter_server::{Greeter, GreeterServer}; +use hello_world::{HelloReply, HelloRequest}; + +pub mod hello_world { + tonic::include_proto!("helloworld"); +} + +#[tokio::main] +async fn main() { + App::new().add_plugin(GrpcPlugin).run().await +} + +#[derive(Clone, Service)] +#[service(grpc = "GreeterServer")] +struct MyGreeter; + +#[tonic::async_trait] +impl Greeter for MyGreeter { + async fn say_hello( + &self, + request: Request, + ) -> Result, Status> { + println!("Got a request from {:?}", request.remote_addr()); + + let reply = hello_world::HelloReply { + message: format!("Hello {}!", request.into_inner().name), + }; + Ok(Response::new(reply)) + } +} diff --git a/examples/grpc-example/src/lib.rs b/examples/grpc-example/src/lib.rs new file mode 100644 index 00000000..e69de29b diff --git a/examples/hello-world-example/src/main.rs b/examples/hello-world-example/src/main.rs index 58d27dd7..b26f3309 100644 --- a/examples/hello-world-example/src/main.rs +++ b/examples/hello-world-example/src/main.rs @@ -32,7 +32,7 @@ async fn hello_world() -> impl IntoResponse { // You can also use the route macro to specify the Http Method and request path. // Path extracts parameters from the HTTP request path -#[route("/hello/:name", method = "GET", method = "POST")] +#[route("/hello/{name}", method = "GET", method = "POST")] async fn hello(Path(name): Path) -> impl IntoResponse { format!("hello {name}") } diff --git a/examples/job-example/src/main.rs b/examples/job-example/src/main.rs index 04e76499..a2eb2168 100644 --- a/examples/job-example/src/main.rs +++ b/examples/job-example/src/main.rs @@ -14,10 +14,14 @@ async fn main() { App::new() .add_plugin(JobPlugin) .add_plugin(SqlxPlugin) + .add_scheduler(|_| Box::new(wait_for_job())) .run() .await; +} +async fn wait_for_job() -> spring::error::Result { tokio::time::sleep(Duration::from_secs(100)).await; + Ok("100s finished".to_string()) } #[cron("1/10 * * * * *")] diff --git a/examples/opentelemetry-example/.env b/examples/opentelemetry-example/.env index b9717b46..e8cc9e7b 100644 --- a/examples/opentelemetry-example/.env +++ b/examples/opentelemetry-example/.env @@ -1,3 +1,5 @@ SERVICE_NAME=opentelemetry-example OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:5081 -OTEL_EXPORTER_OTLP_HEADERS="authorization=Basic cm9vdEBleGFtcGxlLmNvbTo5VjJGQmREbGd1NWlYOU5w,organization=default" \ No newline at end of file +OTEL_EXPORTER_OTLP_HEADERS="authorization=Basic cm9vdEBleGFtcGxlLmNvbTo5VjJGQmREbGd1NWlYOU5w,organization=default" +OTEL_INSTRUMENTATION_HTTP_SERVER_CAPTURE_REQUEST_HEADERS=user-agent,cookie +OTEL_INSTRUMENTATION_HTTP_SERVER_CAPTURE_RESPONSE_HEADERS=content-type \ No newline at end of file diff --git a/examples/opentelemetry-example/Cargo.toml b/examples/opentelemetry-example/Cargo.toml index c8e8ef29..69261963 100644 --- a/examples/opentelemetry-example/Cargo.toml +++ b/examples/opentelemetry-example/Cargo.toml @@ -9,6 +9,8 @@ authors.workspace = true spring = { path = "../../spring" } spring-web = { path = "../../spring-web" } spring-sqlx = { path = "../../spring-sqlx", features = ["postgres"] } -spring-opentelemetry = { path = "../../spring-opentelemetry" } +spring-opentelemetry = { path = "../../spring-opentelemetry", features = [ + "grpc", +] } tokio = { workspace = true } anyhow = { workspace = true } diff --git a/examples/opentelemetry-example/src/main.rs b/examples/opentelemetry-example/src/main.rs index bae3bd6f..0ccfa061 100644 --- a/examples/opentelemetry-example/src/main.rs +++ b/examples/opentelemetry-example/src/main.rs @@ -3,8 +3,10 @@ use spring::{ tracing::{info, info_span, Instrument, Level}, App, }; +use spring_opentelemetry::trace as otel_trace; +use spring_opentelemetry::{global, metrics as otel_metrics}; use spring_opentelemetry::{ - middlewares, KeyValue, OpenTelemetryPlugin, ResourceConfigurator, SERVICE_NAME, SERVICE_VERSION, + KeyValue, OpenTelemetryPlugin, ResourceConfigurator, SERVICE_NAME, SERVICE_VERSION, }; use spring_sqlx::{ sqlx::{self, Row}, @@ -14,6 +16,9 @@ use spring_web::{ axum::response::IntoResponse, error::Result, extractor::{Component, Path}, + middleware::trace::{ + DefaultMakeSpan, DefaultOnEos, DefaultOnRequest, DefaultOnResponse, TraceLayer, + }, Router, WebConfigurator, WebPlugin, }; use spring_web::{get, route}; @@ -35,8 +40,19 @@ async fn main() { } fn router() -> Router { - let http_tracing_layer = middlewares::tracing::HttpLayer::server(Level::INFO); - spring_web::handler::auto_router().layer(http_tracing_layer) + let meter = global::meter(env!("CARGO_PKG_NAME")); + let otel_metrics_layer = otel_metrics::HttpLayer::server(&meter); + let trace_layer = TraceLayer::new_for_http() + .make_span_with(DefaultMakeSpan::default()) + .on_request(DefaultOnRequest::default()) + .on_response(DefaultOnResponse::default()) + .on_eos(DefaultOnEos::default()); + let otel_trace_layer = otel_trace::HttpLayer::server(Level::INFO).export_trace_id(true); + // Note: http_tracing_layer must be added after trace_layer, because axum defaults to adding it first and executing it later. + spring_web::handler::auto_router() + .layer(otel_metrics_layer) + .layer(trace_layer) + .layer(otel_trace_layer) } // The get macro specifies the Http Method and request path. @@ -49,7 +65,7 @@ async fn hello_world() -> impl IntoResponse { // You can also use the route macro to specify the Http Method and request path. // Path extracts parameters from the HTTP request path -#[route("/hello/:name", method = "GET", method = "POST")] +#[route("/hello/{name}", method = "GET", method = "POST")] async fn hello(Path(name): Path) -> impl IntoResponse { info!("hello {name} called"); format!("hello {name}") diff --git a/examples/redis-example/src/main.rs b/examples/redis-example/src/main.rs index 5440742e..7283b4c8 100644 --- a/examples/redis-example/src/main.rs +++ b/examples/redis-example/src/main.rs @@ -25,7 +25,7 @@ async fn list_redis_key(Component(mut redis): Component) -> Result, Path(key): Path, @@ -34,7 +34,7 @@ async fn get_content( Ok(v) } -#[post("/:key")] +#[post("/{key}")] async fn set_content( Component(mut redis): Component, Path(key): Path, diff --git a/examples/sea-orm-example/src/main.rs b/examples/sea-orm-example/src/main.rs index a1ee772f..41126ebe 100644 --- a/examples/sea-orm-example/src/main.rs +++ b/examples/sea-orm-example/src/main.rs @@ -58,7 +58,7 @@ async fn get_todo_list( Ok(Json(rows)) } -#[get("/:id")] +#[get("/{id}")] async fn get_todo_list_items( Component(db): Component, Path(id): Path, diff --git a/examples/web-example/Cargo.toml b/examples/web-example/Cargo.toml index f325df7b..ebf5dfe2 100644 --- a/examples/web-example/Cargo.toml +++ b/examples/web-example/Cargo.toml @@ -16,7 +16,7 @@ serde_json = { workspace = true } jsonwebtoken = "8.3" pem = "3.0" lazy_static = "1.4" -axum-extra = { version = "0.9", features = ["typed-header"] } +axum-extra = { version = "0.10", features = ["typed-header"] } tracing = { workspace = true } # benchmark compare diff --git a/examples/web-example/src/jwt.rs b/examples/web-example/src/jwt.rs index 038b1bdb..a58a7ee3 100644 --- a/examples/web-example/src/jwt.rs +++ b/examples/web-example/src/jwt.rs @@ -4,7 +4,6 @@ use axum_extra::TypedHeader; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation}; use lazy_static::lazy_static; use serde::{Deserialize, Serialize}; -use spring_web::async_trait; use spring_web::axum::http::request::Parts; use spring_web::axum::RequestPartsExt; use spring_web::error::{KnownWebError, Result, WebError}; @@ -34,7 +33,6 @@ impl Claims { } } -#[async_trait] impl FromRequestParts for Claims where S: Send + Sync, diff --git a/examples/web-example/src/main.rs b/examples/web-example/src/main.rs index 819d827a..39ba18f4 100644 --- a/examples/web-example/src/main.rs +++ b/examples/web-example/src/main.rs @@ -33,7 +33,7 @@ async fn hello_world() -> impl IntoResponse { "hello world" } -#[route("/hello/:name", method = "GET", method = "POST")] +#[route("/hello/{name}", method = "GET", method = "POST")] async fn hello(Path(name): Path) -> impl IntoResponse { format!("hello {name}") } diff --git a/examples/web-middleware-example/Cargo.toml b/examples/web-middleware-example/Cargo.toml index 23b95158..8ff319e4 100644 --- a/examples/web-middleware-example/Cargo.toml +++ b/examples/web-middleware-example/Cargo.toml @@ -13,4 +13,4 @@ tokio = { workspace = true, features = ["full", "tracing"] } tower = { workspace = true } tower-http = { workspace = true } anyhow = { workspace = true } -problemdetails = { version = "0.4", features = ["axum"] } +problemdetails = { version = "0.5", features = ["axum"] } diff --git a/spring-grpc/CHANGELOG.md b/spring-grpc/CHANGELOG.md new file mode 100644 index 00000000..b0f69a5c --- /dev/null +++ b/spring-grpc/CHANGELOG.md @@ -0,0 +1,5 @@ +## 0.4.0 + +- **added**: support grpc in tonic ([#132]) + +[#132]: https://github.com/spring-rs/spring-rs/pull/132 diff --git a/spring-grpc/Cargo.toml b/spring-grpc/Cargo.toml new file mode 100644 index 00000000..d7ba694e --- /dev/null +++ b/spring-grpc/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "spring-grpc" +description = "Integration of rust application framework spring-rs and tonic gRPC framework" +version = "0.4.0" +categories = ["web-programming", "network-programming"] +keywords = ["rpc", "grpc", "protobuf", "spring"] +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[features] + +[dependencies] +spring = { path = "../spring", version = "0.4" } +tonic = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true, features = ["log"] } +tokio = { workspace = true, features = ["full"] } +tower = { workspace = true, features = ["full"] } +tower-http = { workspace = true, features = ["full"] } +serde = { workspace = true } +axum = { workspace = true } +http = { workspace = true } +schemars = { workspace = true } +inventory = { workspace = true } diff --git a/spring-grpc/README.md b/spring-grpc/README.md new file mode 100644 index 00000000..e842d67f --- /dev/null +++ b/spring-grpc/README.md @@ -0,0 +1,109 @@ +[![crates.io](https://img.shields.io/crates/v/spring-grpc.svg)](https://crates.io/crates/spring-grpc) +[![Documentation](https://docs.rs/spring-grpc/badge.svg)](https://docs.rs/spring-grpc) + +[tonic](https://github.com/hyperium/tonic) is a Rust-based asynchronous gRPC framework for building high-performance, type-safe gRPC clients and servers. It is built on tokio and hyper, has good performance and ecological integration, and is widely used in microservice communication, remote calls and other scenarios. + +## Dependencies + +```toml +spring-grpc = { version = "" } +tonic = { version = "0.13" } +prost = { version = "0.13" } +``` + +## Configuration items + +```toml +[grpc] +binding = "172.20.10.4" # IP address of the network interface to be bound, default 0.0.0.0 +port = 8000 # Port number to be bound, default 9090 +graceful = true # Whether to enable graceful shutdown, default false +``` + +## Service implementation + +Interface definition based on protobuf protocol + +```proto +syntax = "proto3"; + +package helloworld; + +// The greeting service definition. +service Greeter { + // Sends a greeting + rpc SayHello (HelloRequest) returns (HelloReply) {} +} + +// The request message containing the user's name. +message HelloRequest { + string name = 1; +} + +// The response message containing the greetings +message HelloReply { + string message = 1; +} +``` + +Add protobuf compilation dependency in `Cargo.toml` + +```toml +[build-dependencies] +tonic-build = "0.13" +``` + +Add `build.rs` in the same directory as `Cargo.toml` to compile the interface definition of protobuf and generate the corresponding rust code + +```rust +fn main() -> Result<(), Box> { + tonic_build::compile_protos("proto/helloworld.proto")?; + Ok(()) +} +``` + +Implement the corresponding interface + +```rust +use spring::plugin::service::Service; +use spring::App; +use spring_grpc::GrpcPlugin; +use tonic::{Request, Response, Status}; + +use hello_world::greeter_server::{Greeter, GreeterServer}; +use hello_world::{HelloReply, HelloRequest}; + +/// Import the interface defined by protobuf into the project +pub mod hello_world { + tonic::include_proto!("helloworld"); +} + +#[tokio::main] +async fn main() { + App::new().add_plugin(GrpcPlugin).run().await +} + +/// Derive Service and specify Grpc Server. The Grpc plug-in will automatically register the service on tonic +#[derive(Clone, Service)] +#[service(grpc = "GreeterServer")] +struct MyGreeter; + +/// Implement the interface defined by protobuf +#[tonic::async_trait] +impl Greeter for MyGreeter { + async fn say_hello( + &self, + request: Request, + ) -> Result, Status> { + println!("Got a request from {:?}", request.remote_addr()); + + let reply = hello_world::HelloReply { + message: format!("Hello {}!", request.into_inner().name), + }; + Ok(Response::new(reply)) + } +} +``` + + +Complete code reference [`grpc-example`](https://github.com/spring-rs/spring-rs/tree/master/examples/grpc-example) \ No newline at end of file diff --git a/spring-grpc/README.zh.md b/spring-grpc/README.zh.md new file mode 100644 index 00000000..2e292c10 --- /dev/null +++ b/spring-grpc/README.zh.md @@ -0,0 +1,109 @@ +[![crates.io](https://img.shields.io/crates/v/spring-grpc.svg)](https://crates.io/crates/spring-grpc) +[![Documentation](https://docs.rs/spring-grpc/badge.svg)](https://docs.rs/spring-grpc) + +[tonic](https://github.com/hyperium/tonic) 是一个基于 Rust 的异步 gRPC 框架,用于构建高性能、类型安全的 gRPC 客户端和服务端。它建立在 tokio 和 hyper 之上,拥有良好的性能和生态集成,广泛应用于微服务通信、远程调用等场景。 + +## 依赖 + +```toml +spring-grpc = { version = "" } +tonic = { version = "0.13" } +prost = { version = "0.13" } +``` + +## 配置项 + +```toml +[grpc] +binding = "172.20.10.4" # 要绑定的网卡IP地址,默认0.0.0.0 +port = 8000 # 要绑定的端口号,默认9090 +graceful = true # 是否开启优雅停机, 默认false +``` + +## 服务实现 + +基于protobuf协议定义接口 + +```proto +syntax = "proto3"; + +package helloworld; + +// The greeting service definition. +service Greeter { + // Sends a greeting + rpc SayHello (HelloRequest) returns (HelloReply) {} +} + +// The request message containing the user's name. +message HelloRequest { + string name = 1; +} + +// The response message containing the greetings +message HelloReply { + string message = 1; +} +``` + +在`Cargo.toml`中添加protobuf编译依赖 + +```toml +[build-dependencies] +tonic-build = "0.13" +``` + +在`Cargo.toml`同级目录添加`build.rs`编译protobuf的接口定义生成对应的rust代码 + +```rust +fn main() -> Result<(), Box> { + tonic_build::compile_protos("proto/helloworld.proto")?; + Ok(()) +} +``` + +实现相应的接口 + +```rust +use spring::plugin::service::Service; +use spring::App; +use spring_grpc::GrpcPlugin; +use tonic::{Request, Response, Status}; + +use hello_world::greeter_server::{Greeter, GreeterServer}; +use hello_world::{HelloReply, HelloRequest}; + +/// 将protobuf定义的接口导入到项目中 +pub mod hello_world { + tonic::include_proto!("helloworld"); +} + +#[tokio::main] +async fn main() { + App::new().add_plugin(GrpcPlugin).run().await +} + +/// 派生Service,并指定Grpc Server,Grpc插件会自动将服务注册到tonic上 +#[derive(Clone, Service)] +#[service(grpc = "GreeterServer")] +struct MyGreeter; + +/// 实现protobuf定义的接口 +#[tonic::async_trait] +impl Greeter for MyGreeter { + async fn say_hello( + &self, + request: Request, + ) -> Result, Status> { + println!("Got a request from {:?}", request.remote_addr()); + + let reply = hello_world::HelloReply { + message: format!("Hello {}!", request.into_inner().name), + }; + Ok(Response::new(reply)) + } +} +``` + + +完整代码参考[`grpc-example`](https://github.com/spring-rs/spring-rs/tree/master/examples/grpc-example) diff --git a/spring-grpc/src/config.rs b/spring-grpc/src/config.rs new file mode 100644 index 00000000..c34d6782 --- /dev/null +++ b/spring-grpc/src/config.rs @@ -0,0 +1,120 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use spring::config::Configurable; +use std::{ + net::{IpAddr, Ipv4Addr}, + time::Duration, +}; + +/// spring-grpc Config +#[derive(Debug, Configurable, JsonSchema, Deserialize)] +#[config_prefix = "grpc"] +pub struct GrpcConfig { + #[serde(default = "default_binding")] + pub(crate) binding: IpAddr, + #[serde(default = "default_port")] + pub(crate) port: u16, + + /// Set the concurrency limit applied to on requests inbound per connection. + pub(crate) concurrency_limit_per_connection: Option, + + /// Set a timeout on for all request handlers. + pub(crate) timeout: Option, + + /// Sets the [`SETTINGS_INITIAL_WINDOW_SIZE`][spec] option for HTTP2 + /// stream-level flow control. + /// + /// Default is 65,535 + /// + /// [spec]: https://httpwg.org/specs/rfc9113.html#InitialWindowSize + pub(crate) initial_stream_window_size: Option, + + /// Sets the max connection-level flow control for HTTP2 + pub(crate) initial_connection_window_size: Option, + + /// Sets the [`SETTINGS_MAX_CONCURRENT_STREAMS`][spec] option for HTTP2 + /// connections. + pub(crate) max_concurrent_streams: Option, + + /// Sets the maximum time option in milliseconds that a connection may exist + /// + /// Default is no limit (`None`). + pub(crate) max_connection_age: Option, + + /// Set whether HTTP2 Ping frames are enabled on accepted connections. + /// + /// If `None` is specified, HTTP2 keepalive is disabled, otherwise the duration + /// specified will be the time interval between HTTP2 Ping frames. + /// The timeout for receiving an acknowledgement of the keepalive ping + /// can be set with [`Server::http2_keepalive_timeout`]. + /// + /// Default is no HTTP2 keepalive (`None`) + pub(crate) http2_keepalive_interval: Option, + + /// Sets a timeout for receiving an acknowledgement of the keepalive ping. + /// + /// If the ping is not acknowledged within the timeout, the connection will be closed. + /// Does nothing if http2_keep_alive_interval is disabled. + /// + /// Default is 20 seconds. + pub(crate) http2_keepalive_timeout: Option, + + /// Sets whether to use an adaptive flow control. Defaults to false. + /// Enabling this will override the limits set in http2_initial_stream_window_size and + /// http2_initial_connection_window_size. + pub(crate) http2_adaptive_window: Option, + + /// Configures the maximum number of pending reset streams allowed before a GOAWAY will be sent. + /// + /// This will default to whatever the default in h2 is. As of v0.3.17, it is 20. + /// + /// See for more information. + pub(crate) http2_max_pending_accept_reset_streams: Option, + + /// Set whether TCP keepalive messages are enabled on accepted connections. + /// + /// If `None` is specified, keepalive is disabled, otherwise the duration + /// specified will be the time to remain idle before sending TCP keepalive + /// probes. + /// + /// Default is no keepalive (`None`) + pub(crate) tcp_keepalive: Option, + + /// Set the value of `TCP_NODELAY` option for accepted connections. Enabled by default. + #[serde(default)] + pub(crate) tcp_nodelay: bool, + + /// Sets the max size of received header frames. + /// + /// This will default to whatever the default in hyper is. As of v1.4.1, it is 16 KiB. + pub(crate) http2_max_header_list_size: Option, + + /// Sets the maximum frame size to use for HTTP2. + /// + /// Passing `None` will do nothing. + /// + /// If not set, will default from underlying transport. + pub(crate) max_frame_size: Option, + + /// Allow this server to accept http1 requests. + /// + /// Accepting http1 requests is only useful when developing `grpc-web` + /// enabled services. If this setting is set to `true` but services are + /// not correctly configured to handle grpc-web requests, your server may + /// return confusing (but correct) protocol errors. + /// + /// Default is `false`. + #[serde(default)] + pub(crate) accept_http1: bool, + + #[serde(default)] + pub(crate) graceful: bool, +} + +fn default_binding() -> IpAddr { + IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)) +} + +fn default_port() -> u16 { + 9090 +} diff --git a/spring-grpc/src/lib.rs b/spring-grpc/src/lib.rs new file mode 100644 index 00000000..3e77cf1b --- /dev/null +++ b/spring-grpc/src/lib.rs @@ -0,0 +1,174 @@ +//! [![spring-rs](https://img.shields.io/github/stars/spring-rs/spring-rs)](https://spring-rs.github.io/docs/plugins/spring-grpc) +#![doc(html_favicon_url = "https://spring-rs.github.io/favicon.ico")] +#![doc(html_logo_url = "https://spring-rs.github.io/logo.svg")] +pub mod config; + +pub use tonic; + +use anyhow::Context; +use config::GrpcConfig; +use http::Request; +use spring::{ + app::AppBuilder, + config::ConfigRegistry, + error::Result, + plugin::{component::ComponentRef, ComponentRegistry, MutableComponentRegistry, Plugin}, + App, +}; +use std::{convert::Infallible, net::SocketAddr, sync::Arc}; +use tonic::{ + async_trait, + body::Body, + server::NamedService, + service::{Routes, RoutesBuilder}, + transport::Server, +}; +use tower::Service; + +/// Grpc Configurator +pub trait GrpcConfigurator { + /// add grpc service to app registry + fn add_service(&mut self, service: S) -> &mut Self + where + S: Service, Error = Infallible> + + NamedService + + Clone + + Send + + Sync + + 'static, + S::Response: axum::response::IntoResponse, + S::Future: Send + 'static; +} + +impl GrpcConfigurator for AppBuilder { + fn add_service(&mut self, svc: S) -> &mut Self + where + S: Service, Error = Infallible> + + NamedService + + Clone + + Send + + Sync + + 'static, + S::Response: axum::response::IntoResponse, + S::Future: Send + 'static, + { + if let Some(routes) = self.get_component_ref::() { + unsafe { + let raw_ptr = ComponentRef::into_raw(routes); + let routes = &mut *(raw_ptr as *mut RoutesBuilder); + routes.add_service(svc); + } + self + } else { + let mut route_builder = Routes::builder(); + route_builder.add_service(svc); + self.add_component(route_builder) + } + } +} + +/// Grpc Plugin Definition +pub struct GrpcPlugin; + +#[async_trait] +impl Plugin for GrpcPlugin { + async fn build(&self, app: &mut AppBuilder) { + let config = app + .get_config::() + .expect("grpc plugin config load failed"); + + app.add_scheduler(move |app| Box::new(Self::schedule(app, config))); + } +} + +impl GrpcPlugin { + async fn schedule(app: Arc, config: GrpcConfig) -> Result { + // Get the router in the final schedule step + let routes_builder = app.get_component::(); + + let routes = if let Some(routes_builder) = routes_builder { + routes_builder.routes() + } else { + return Ok( + "The grpc plugin does not register any routes, so no scheduling is performed" + .to_string(), + ); + }; + + let mut server = Server::builder() + .accept_http1(config.accept_http1) + .http2_adaptive_window(config.http2_adaptive_window) + .http2_keepalive_interval(config.http2_keepalive_interval) + .http2_keepalive_timeout(config.http2_keepalive_timeout) + .http2_max_header_list_size(config.http2_max_header_list_size) + .http2_max_pending_accept_reset_streams(config.http2_max_pending_accept_reset_streams) + .initial_connection_window_size(config.initial_connection_window_size) + .initial_stream_window_size(config.initial_stream_window_size) + .max_concurrent_streams(config.max_concurrent_streams) + .max_frame_size(config.max_frame_size) + .tcp_keepalive(config.tcp_keepalive) + .tcp_nodelay(config.tcp_nodelay); + + if let Some(max_connection_age) = config.max_connection_age { + server = server.max_connection_age(max_connection_age); + } + if let Some(timeout) = config.timeout { + server = server.timeout(timeout); + } + if let Some(concurrency_limit_per_connection) = config.concurrency_limit_per_connection { + server = server.concurrency_limit_per_connection(concurrency_limit_per_connection); + } + + server = Self::apply_middleware(server); + + let addr = SocketAddr::new(config.binding, config.port); + tracing::info!("tonic grpc service bind tcp listener: {}", addr); + + let router = server.add_routes(routes); + if config.graceful { + router + .serve_with_shutdown(addr, shutdown_signal()) + .await + .with_context(|| format!("bind tcp listener failed:{}", addr))?; + } else { + router + .serve(addr) + .await + .with_context(|| format!("bind tcp listener failed:{}", addr))?; + } + Ok("tonic server schedule finished".to_string()) + } + + fn apply_middleware(_server: Server) -> Server { + // TODO: add middleware + _server + } +} + +async fn shutdown_signal() { + let ctrl_c = async { + tokio::signal::ctrl_c() + .await + .expect("failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("failed to install signal handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => { + tracing::info!("Received Ctrl+C signal, waiting for tonic grpc server shutdown") + }, + _ = terminate => { + tracing::info!("Received kill signal, waiting for tonic grpc server shutdown") + }, + } +} diff --git a/spring-job/CHANGELOG.md b/spring-job/CHANGELOG.md index fd357af2..de1c97ab 100644 --- a/spring-job/CHANGELOG.md +++ b/spring-job/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.4.0 + +- **breaking**: upgrade `spring-macros` 0.3 to 0.4 ([#132]) + +[#132]: https://github.com/spring-rs/spring-rs/pull/132 + +## 0.3.1 + +- **changed**: use Local timezone instead of utc timezone ([#111]) + +[#111]: https://github.com/spring-rs/spring-rs/pull/111 + ## 0.3.0 - **breaking**: refactor dependency inject ([#105]) diff --git a/spring-job/Cargo.toml b/spring-job/Cargo.toml index c334a926..4d004686 100644 --- a/spring-job/Cargo.toml +++ b/spring-job/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "spring-job" description = "Integrate tokio-cron-scheduler with spring-rs framework" -version = "0.3.0" +version = "0.4.0" categories = ["date-and-time"] keywords = ["cron-scheduler", "task-scheduling", "cron-job", "spring"] edition.workspace = true @@ -10,8 +10,8 @@ authors.workspace = true repository.workspace = true [dependencies] -spring = { path = "../spring", version = "0.3" } -spring-macros = { path = "../spring-macros", version = "0.3" } +spring = { path = "../spring", version = "0.4" } +spring-macros = { path = "../spring-macros", version = "0.4" } tokio-cron-scheduler = { workspace = true, features = ["signal"] } anyhow = { workspace = true } serde = { workspace = true } diff --git a/spring-job/src/job.rs b/spring-job/src/job.rs index 82196634..648d0827 100644 --- a/spring-job/src/job.rs +++ b/spring-job/src/job.rs @@ -70,11 +70,13 @@ impl Job { Box::pin(Self::call(handler.clone(), job_id, jobs, app.clone())) }, ), - Trigger::Cron(schedule) => { - tokio_cron_scheduler::Job::new_async(schedule.as_str(), move |job_id, jobs| { + Trigger::Cron(schedule) => tokio_cron_scheduler::Job::new_async_tz( + schedule.as_str(), + chrono::Local, + move |job_id, jobs| { Box::pin(Self::call(handler.clone(), job_id, jobs, app.clone())) - }) - } + }, + ), } .context("build job failed") .unwrap() diff --git a/spring-job/src/lib.rs b/spring-job/src/lib.rs index 39b33fac..ce25dd5a 100644 --- a/spring-job/src/lib.rs +++ b/spring-job/src/lib.rs @@ -145,6 +145,6 @@ impl JobPlugin { // Start the scheduler sched.start().await.context("job scheduler start failed")?; - Ok("job schedule finished".to_string()) + Ok("job schedule started".to_string()) } } diff --git a/spring-macros/CHANGELOG.md b/spring-macros/CHANGELOG.md index d572ce17..e71485e7 100644 --- a/spring-macros/CHANGELOG.md +++ b/spring-macros/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.4.0 + +- **breaking**: Refactor service macro to support grpc service ([#132]) + +[#132]: https://github.com/spring-rs/spring-rs/pull/132 + +## 0.3.1 + +- **added**: Added prototype service derived macro generation `build` function ([#112]) + +[#112]: https://github.com/spring-rs/spring-rs/pull/112 + ## 0.3.0 - **breaking**: refactor dependency inject ([#105]) diff --git a/spring-macros/Cargo.toml b/spring-macros/Cargo.toml index 877755ea..6d73ad3a 100644 --- a/spring-macros/Cargo.toml +++ b/spring-macros/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "spring-macros" description = "spring-rs Procedural Macros implementation" -version = "0.3.0" +version = "0.4.0" categories = ["development-tools::procedural-macro-helpers"] keywords = ["proc-macro", "web", "cron", "stream", "spring"] edition.workspace = true diff --git a/spring-macros/src/config.rs b/spring-macros/src/config.rs index 16953997..4ea4658c 100644 --- a/spring-macros/src/config.rs +++ b/spring-macros/src/config.rs @@ -21,7 +21,7 @@ fn get_prefix(input: &syn::DeriveInput) -> syn::Result { .attrs .iter() .filter(|attr| attr.path().is_ident("config_prefix")) - .last(); + .next_back(); if let Some(syn::Attribute { meta: syn::Meta::NameValue(name_value), diff --git a/spring-macros/src/inject.rs b/spring-macros/src/inject.rs index 90adf36c..5507db31 100644 --- a/spring-macros/src/inject.rs +++ b/spring-macros/src/inject.rs @@ -19,6 +19,7 @@ enum InjectableType { ComponentRef(syn::Path), ConfigRef(syn::Path), FuncCall(syn::ExprCall), + PrototypeArg(syn::Type), } impl InjectableType { @@ -30,8 +31,13 @@ impl InjectableType { Self::ComponentRef(_) => 3, Self::ConfigRef(_) => 4, Self::FuncCall(_) => 5, + Self::PrototypeArg(_) => 6, } } + + fn is_arg(&self) -> bool { + matches!(self, Self::PrototypeArg(_)) + } } enum InjectableAttr { @@ -39,65 +45,72 @@ enum InjectableAttr { Config, FuncCall(syn::ExprCall), } + struct Injectable { + is_prototype: bool, ty: InjectableType, field_name: syn::Ident, } impl Injectable { - fn new(field: syn::Field) -> syn::Result { - let ty = Self::compute_type(&field)?; + fn new(field: syn::Field, is_prototype: bool) -> syn::Result { + let ty = Self::compute_type(&field, is_prototype)?; let field_name = field.ident.ok_or_else(inject_error_tip)?; - Ok(Self { ty, field_name }) + Ok(Self { + is_prototype, + ty, + field_name, + }) } - fn compute_type(field: &syn::Field) -> syn::Result { - let ty = if let syn::Type::Path(path) = &field.ty { - &path.path - } else { - Err(inject_error_tip())? - }; - let inject_attr = field - .attrs - .iter() - .find(|attr| attr.path().is_ident("inject")); + fn compute_type(field: &syn::Field, is_prototype: bool) -> syn::Result { + if let syn::Type::Path(path) = &field.ty { + let ty = &path.path; + let inject_attr = field + .attrs + .iter() + .find(|attr| attr.path().is_ident("inject")); - if let Some(inject_attr) = inject_attr { - if let Meta::List(MetaList { tokens, .. }) = &inject_attr.meta { - let attr = syn::parse::(tokens.clone().into())?; - return Ok(attr.make_type(ty)); - } else { - Err(syn::Error::new_spanned( - inject_attr, - "invalid inject definition, expected #[inject(component|config|func(args))]", - ))?; + if let Some(inject_attr) = inject_attr { + if let Meta::List(MetaList { tokens, .. }) = &inject_attr.meta { + let attr = syn::parse::(tokens.clone().into())?; + return Ok(attr.make_type(ty)); + } else { + Err(syn::Error::new_spanned( + inject_attr, + "invalid inject definition, expected #[inject(component|config|func(args))]", + ))?; + } + } + let last_path_segment = ty.segments.last().ok_or_else(inject_error_tip)?; + if last_path_segment.ident == "ComponentRef" { + return Ok(InjectableType::ComponentRef(Self::get_argument_type( + &last_path_segment.arguments, + )?)); + } + if last_path_segment.ident == "ConfigRef" { + return Ok(InjectableType::ConfigRef(Self::get_argument_type( + &last_path_segment.arguments, + )?)); + } + if !is_prototype && last_path_segment.ident == "Option" { + return Ok(InjectableType::Option); } } - let last_path_segment = ty.segments.last().ok_or_else(inject_error_tip)?; - if last_path_segment.ident == "Option" { - return Ok(InjectableType::Option); - } - if last_path_segment.ident == "ComponentRef" { - return Ok(InjectableType::ComponentRef(Self::get_argument_type( - &last_path_segment.arguments, - )?)); - } - if last_path_segment.ident == "ConfigRef" { - return Ok(InjectableType::ConfigRef(Self::get_argument_type( - &last_path_segment.arguments, - )?)); - } - let field_name = &field - .ident - .clone() - .map(|ident| ident.to_string()) - .ok_or_else(inject_error_tip)?; - Err(syn::Error::new_spanned( + if is_prototype { + Ok(InjectableType::PrototypeArg(field.ty.clone())) + } else { + let field_name = &field + .ident + .clone() + .map(|ident| ident.to_string()) + .ok_or_else(inject_error_tip)?; + Err(syn::Error::new_spanned( field, format!( "{field_name} field missing inject definition, expected #[inject(component|config|func(args))]", - ), - )) + ))) + } } fn get_argument_type(path_args: &PathArguments) -> syn::Result { @@ -146,7 +159,11 @@ impl InjectableAttr { impl ToTokens for Injectable { fn to_tokens(&self, tokens: &mut TokenStream) { - let Self { ty, field_name } = self; + let Self { + is_prototype, + ty, + field_name, + } = self; match ty { InjectableType::Option => { tokens.extend(quote! { @@ -154,92 +171,270 @@ impl ToTokens for Injectable { }); } InjectableType::Component(type_path) => { - tokens.extend(quote! { - let #field_name = app.try_get_component::<#type_path>()?; - }); + if *is_prototype { + tokens.extend(quote! { + let #field_name = ::spring::App::global().try_get_component::<#type_path>()?; + }); + } else { + tokens.extend(quote! { + let #field_name = app.try_get_component::<#type_path>()?; + }); + } } InjectableType::Config(type_path) => { - tokens.extend(quote! { - let #field_name = app.get_config::<#type_path>()?; - }); + if *is_prototype { + tokens.extend(quote! { + let #field_name = ::spring::App::global().get_config::<#type_path>()?; + }); + } else { + tokens.extend(quote! { + let #field_name = app.get_config::<#type_path>()?; + }); + } } InjectableType::ComponentRef(type_path) => { - tokens.extend(quote! { - let #field_name = app.try_get_component_ref::<#type_path>()?; - }); + if *is_prototype { + tokens.extend(quote! { + let #field_name = ::spring::App::global().try_get_component_ref::<#type_path>()?; + }); + } else { + tokens.extend(quote! { + let #field_name = app.try_get_component_ref::<#type_path>()?; + }); + } } InjectableType::ConfigRef(type_path) => { - tokens.extend(quote! { - let #field_name = ::spring::config::ConfigRef::new(app.get_config::<#type_path>()?); - }); + if *is_prototype { + tokens.extend(quote! { + let #field_name = ::spring::config::ConfigRef::new(::spring::App::global().get_config::<#type_path>()?); + }); + } else { + tokens.extend(quote! { + let #field_name = ::spring::config::ConfigRef::new(app.get_config::<#type_path>()?); + }); + } } InjectableType::FuncCall(func_call) => { tokens.extend(quote! { let #field_name = #func_call; }); } + InjectableType::PrototypeArg(type_path) => { + // as func args + tokens.extend(quote! { + #field_name: #type_path + }); + } } } } struct Service { + generics: syn::Generics, + ident: proc_macro2::Ident, + attr: Option, fields: Vec, } +enum ServiceAttr { + Grpc(syn::Path), + Prototype(syn::LitStr), +} + impl Service { - fn new(fields: syn::Fields) -> syn::Result { - let mut fields = fields - .into_iter() - .map(Injectable::new) - .collect::>>()?; + fn new(input: syn::DeriveInput) -> syn::Result { + let syn::DeriveInput { + attrs, + ident, + generics, + data, + .. + } = input; + let service_attr = attrs + .iter() + .find(|a| a.path().is_ident("service")) + .and_then(|attr| attr.parse_args_with(Self::parse_service_attr).ok()); - // Put FuncCall at the end + let is_prototype = matches!(&service_attr, Some(ServiceAttr::Prototype(_))); + let mut fields = if let syn::Data::Struct(data) = data { + data.fields + .into_iter() + .map(|f| Injectable::new(f, is_prototype)) + .collect::>>()? + } else { + return Err(inject_error_tip()); + }; fields.sort_by_key(|f| f.ty.order()); - Ok(Self { fields }) + + // Put FuncCall at the end + Ok(Self { + generics, + ident, + attr: service_attr, + fields, + }) + } + fn parse_service_attr(input: syn::parse::ParseStream) -> syn::Result { + let mut grpc: Option = None; + let mut prototype: Option = None; + + while !input.is_empty() { + let ident: syn::Ident = input.parse()?; + + if input.peek(syn::Token![=]) { + input.parse::()?; + let value: syn::LitStr = input.parse()?; + + match ident.to_string().as_str() { + "grpc" => { + if grpc.is_some() || prototype.is_some() { + return Err(syn::Error::new_spanned( + ident, + "Only one of `grpc` or `prototype` is allowed", + )); + } + grpc = Some(value.parse()?); + } + "prototype" => { + if prototype.is_some() || grpc.is_some() { + return Err(syn::Error::new_spanned( + ident, + "Only one of `grpc` or `prototype` is allowed", + )); + } + prototype = Some(value); + } + other => { + return Err(syn::Error::new_spanned( + ident, + format!("Unknown key `{}` in #[service(...)], expected `grpc` or `prototype`", other), + )); + } + } + } else { + // 标志形式:#[service(prototype)] + match ident.to_string().as_str() { + "prototype" => { + if prototype.is_some() || grpc.is_some() { + return Err(syn::Error::new_spanned( + ident, + "Only one of `grpc` or `prototype` is allowed", + )); + } + prototype = Some(syn::LitStr::new("build", Span::call_site())); + // 默认build + } + "grpc" => { + return Err(syn::Error::new_spanned( + ident, + "`grpc` must have a value like `grpc = \"...\"`", + )); + } + other => { + return Err(syn::Error::new_spanned( + ident, + format!("Unknown key `{}` in #[service(...)]", other), + )); + } + } + } + + // 跳过逗号 + if input.peek(syn::Token![,]) { + input.parse::()?; + } + } + + match (grpc, prototype) { + (Some(path), None) => Ok(ServiceAttr::Grpc(path)), + (None, Some(litstr_opt)) => Ok(ServiceAttr::Prototype(litstr_opt)), + (None, None) => Err(syn::Error::new( + input.span(), + "Expected at least one of `grpc` or `prototype`", + )), + _ => unreachable!(), + } } } impl ToTokens for Service { fn to_tokens(&self, tokens: &mut TokenStream) { - let field_names: Vec<&syn::Ident> = self.fields.iter().map(|f| &f.field_name).collect(); - let fields = &self.fields; - tokens.extend(quote! { - #(#fields)* - Ok(Self { #(#field_names),* }) - }); - } -} + let Self { + generics, + ident, + attr, + fields, + } = self; + let field_names: Vec<&syn::Ident> = fields.iter().map(|f| &f.field_name).collect(); -pub(crate) fn expand_derive(input: syn::DeriveInput) -> syn::Result { - let service = if let syn::Data::Struct(data) = input.data { - Service::new(data.fields)? - } else { - return Err(inject_error_tip()); - }; - let ident = input.ident; - let service_registrar = - syn::Ident::new(&format!("__ServiceRegistrarFor_{ident}"), ident.span()); - - let output = quote! { - impl ::spring::plugin::service::Service for #ident { - fn build(app: &R) -> ::spring::error::Result - where - R: ::spring::plugin::ComponentRegistry + ::spring::config::ConfigRegistry - { - #service + let output = match attr { + Some(ServiceAttr::Prototype(build)) => { + let fn_name = syn::Ident::new(&build.value(), build.span()); + let (args, fields): (Vec<&Injectable>, Vec<&Injectable>) = + fields.iter().partition(|f| f.ty.is_arg()); + let syn::Generics { + lt_token, + params, + gt_token, + .. + } = generics; + quote! { + impl #lt_token #params #gt_token #ident #generics { + pub fn #fn_name(#(#args),*) -> ::spring::error::Result { + use ::spring::plugin::ComponentRegistry; + use ::spring::config::ConfigRegistry; + #(#fields)* + Ok(Self { #(#field_names),* }) + } + } + } } - } - #[allow(non_camel_case_types)] - struct #service_registrar; - impl ::spring::plugin::service::ServiceRegistrar for #service_registrar{ - fn install_service(&self, app: &mut ::spring::app::AppBuilder)->::spring::error::Result<()> { - use ::spring::plugin::MutableComponentRegistry; - app.add_component(#ident::build(app)?); - Ok(()) + _ => { + let service_registrar = + syn::Ident::new(&format!("__ServiceRegistrarFor_{ident}"), ident.span()); + let service_installer = match attr { + Some(ServiceAttr::Grpc(server)) => { + quote! { + use ::spring::plugin::MutableComponentRegistry; + use ::spring_grpc::GrpcConfigurator; + let service = #ident::build(app)?; + let grpc_server = #server::new(service.clone()); + app.add_component(service).add_service(grpc_server); + } + } + _ => { + quote! { + use ::spring::plugin::MutableComponentRegistry; + app.add_component(#ident::build(app)?); + } + } + }; + quote! { + impl ::spring::plugin::service::Service for #ident { + fn build(app: &R) -> ::spring::error::Result + where + R: ::spring::plugin::ComponentRegistry + ::spring::config::ConfigRegistry + { + #(#fields)* + Ok(Self { #(#field_names),* }) + } + } + #[allow(non_camel_case_types)] + struct #service_registrar; + impl ::spring::plugin::service::ServiceRegistrar for #service_registrar{ + fn install_service(&self, app: &mut ::spring::app::AppBuilder)->::spring::error::Result<()> { + #service_installer + Ok(()) + } + } + ::spring::submit_service!(#service_registrar); + } } - } - ::spring::submit_service!(#service_registrar); - }; + }; + tokens.extend(output); + } +} - Ok(output) +pub(crate) fn expand_derive(input: syn::DeriveInput) -> syn::Result { + Ok(Service::new(input)?.into_token_stream()) } diff --git a/spring-macros/src/lib.rs b/spring-macros/src/lib.rs index 1daa3911..20445474 100644 --- a/spring-macros/src/lib.rs +++ b/spring-macros/src/lib.rs @@ -226,7 +226,7 @@ pub fn derive_config(input: TokenStream) -> TokenStream { } /// Injectable Servcie -#[proc_macro_derive(Service, attributes(inject))] +#[proc_macro_derive(Service, attributes(service, inject))] pub fn derive_service(input: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(input as DeriveInput); diff --git a/spring-mail/CHANGELOG.md b/spring-mail/CHANGELOG.md index c721920e..0181592c 100644 --- a/spring-mail/CHANGELOG.md +++ b/spring-mail/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.4.0 + +- **breaking**: upgrade `spring-macros` 0.3 to 0.4 ([#132]) + +[#132]: https://github.com/spring-rs/spring-rs/pull/132 + ## 0.3.0 - **breaking**: refactor dependency inject ([#105]) diff --git a/spring-mail/Cargo.toml b/spring-mail/Cargo.toml index 0414e07f..4695c2b5 100644 --- a/spring-mail/Cargo.toml +++ b/spring-mail/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "spring-mail" description = "Integrate lettre into spring-rs to send emails" -version = "0.3.0" +version = "0.4.0" categories = ["email"] keywords = ["email", "smtp", "spring"] edition.workspace = true @@ -11,7 +11,7 @@ repository.workspace = true [dependencies] lettre = { workspace = true, features = ["tokio1-native-tls", "serde"] } -spring = { path = "../spring", version = "0.3" } +spring = { path = "../spring", version = "0.4" } serde = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true, features = ["log"] } diff --git a/spring-opentelemetry/CHANGELOG b/spring-opentelemetry/CHANGELOG index b19a8c9a..2bf61cb6 100644 --- a/spring-opentelemetry/CHANGELOG +++ b/spring-opentelemetry/CHANGELOG @@ -1,5 +1,25 @@ # Changelog +## 0.4.1 + +- **breaking**: upgrade `spring` 0.3 to 0.4 ([#132]) + +[#132]: https://github.com/spring-rs/spring-rs/pull/132 + +## 0.4.0 + +- **breaking**: Refactoring the code ([#130]) +- **added**: opentelemetry instrument support capturing HTTP request and response headers ([#130]) + +[#130]: https://github.com/spring-rs/spring-rs/pull/130 + +## 0.3.1 + +- **added**: support export trace id ([#127]) +- **changed**: upgrade opentelemetry `0.26` to `0.29` ([#127]) + +[#127]: https://github.com/spring-rs/spring-rs/pull/127 + ## 0.3.0 - **breaking**: refactor dependency inject ([#105]) diff --git a/spring-opentelemetry/Cargo.toml b/spring-opentelemetry/Cargo.toml index 7cbadced..a3f9d7a2 100644 --- a/spring-opentelemetry/Cargo.toml +++ b/spring-opentelemetry/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "spring-opentelemetry" description = "Integration of spring-rs framework and open-telemetry" -version = "0.3.0" +version = "0.4.1" categories = [ "development-tools::debugging", "development-tools::profiling", @@ -15,12 +15,15 @@ authors.workspace = true repository.workspace = true [features] +default = ["grpc"] jaeger = ["opentelemetry-jaeger-propagator"] zipkin = ["opentelemetry-zipkin"] more-resource = ["opentelemetry-resource-detectors"] +grpc = ["opentelemetry-otlp/grpc-tonic"] +http = ["opentelemetry-otlp/http-proto"] [dependencies] -spring = { path = "../spring", version = "0.3" } +spring = { path = "../spring", version = "0.4" } serde = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true, features = ["log"] } @@ -39,9 +42,12 @@ opentelemetry-zipkin = { workspace = true, optional = true } opentelemetry-resource-detectors = { workspace = true, optional = true } tonic = { workspace = true } http = { workspace = true } +http-body = { workspace = true } tracing-opentelemetry = { workspace = true } tracing-subscriber = { workspace = true } tower = { workspace = true } +tower-layer = { workspace = true } +tower-service = { workspace = true } axum = { workspace = true } futures-util = { workspace = true } pin-project = { workspace = true } diff --git a/spring-opentelemetry/src/lib.rs b/spring-opentelemetry/src/lib.rs index 365f6779..22285caf 100644 --- a/spring-opentelemetry/src/lib.rs +++ b/spring-opentelemetry/src/lib.rs @@ -2,8 +2,11 @@ #![doc(html_favicon_url = "https://spring-rs.github.io/favicon.ico")] #![doc(html_logo_url = "https://spring-rs.github.io/logo.svg")] -pub mod middlewares; +pub mod metrics; +pub mod trace; +pub mod util; +use opentelemetry_otlp::{LogExporter, MetricExporter, SpanExporter}; #[rustfmt::skip] pub use opentelemetry_otlp::{ OTEL_EXPORTER_OTLP_COMPRESSION, @@ -31,35 +34,32 @@ pub use opentelemetry_sdk::Resource; pub use opentelemetry_semantic_conventions::resource::*; use anyhow::Context; +use opentelemetry::propagation::TextMapCompositePropagator; use opentelemetry::trace::TracerProvider; use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge; -use opentelemetry_sdk::logs::LoggerProvider; +use opentelemetry_sdk::logs::SdkLoggerProvider; use opentelemetry_sdk::metrics::SdkMeterProvider; -use opentelemetry_sdk::propagation::TraceContextPropagator; -use opentelemetry_sdk::trace::{self as sdktrace, BatchConfig}; -use opentelemetry_sdk::{resource, runtime}; +use opentelemetry_sdk::propagation::{BaggagePropagator, TraceContextPropagator}; +use opentelemetry_sdk::trace::SdkTracerProvider; use opentelemetry_semantic_conventions::attribute; -use spring::async_trait; -use spring::config::env::Env; use spring::plugin::component::ComponentRef; use spring::plugin::{ComponentRegistry, MutableComponentRegistry}; use spring::{app::AppBuilder, error::Result, plugin::Plugin}; -use std::time::Duration; use tracing_opentelemetry::{MetricsLayer, OpenTelemetryLayer}; +/// Routers collection +pub type KeyValues = Vec; + pub struct OpenTelemetryPlugin; -#[async_trait] impl Plugin for OpenTelemetryPlugin { fn immediately_build(&self, app: &mut AppBuilder) { - let resource = Self::get_resource_attr(app.get_env()); - let resource = match app.get_component_ref::() { - Some(r) => resource.merge(r), - None => resource, - }; + let resource = Self::build_resource(app); let log_provider = Self::init_logs(resource.clone()); let meter_provider = Self::init_metrics(resource.clone()); - let tracer = Self::init_tracer(resource); + let tracer_provider = Self::init_tracer(resource); + + let tracer = tracer_provider.tracer(env!("CARGO_PKG_NAME")); let log_layer = OpenTelemetryTracingBridge::new(&log_provider); let metric_layer = MetricsLayer::new(meter_provider.clone()); @@ -68,7 +68,13 @@ impl Plugin for OpenTelemetryPlugin { app.add_layer(trace_layer) .add_layer(log_layer) .add_layer(metric_layer) - .add_shutdown_hook(move |_| Box::new(Self::shutdown(meter_provider, log_provider))); + .add_shutdown_hook(move |_| { + Box::new(Self::shutdown( + tracer_provider, + meter_provider, + log_provider, + )) + }); } fn immediately(&self) -> bool { @@ -77,22 +83,63 @@ impl Plugin for OpenTelemetryPlugin { } impl OpenTelemetryPlugin { - fn init_logs(resource: Resource) -> LoggerProvider { - opentelemetry_otlp::new_pipeline() - .logging() - .with_exporter(opentelemetry_otlp::new_exporter().tonic()) + fn init_logs(resource: Resource) -> SdkLoggerProvider { + let exporter = { + #[cfg(feature = "http")] + { + LogExporter::builder() + .with_http() + .build() + .expect("build http log exporter failed") + } + + #[cfg(all(not(feature = "http"), feature = "grpc"))] + { + LogExporter::builder() + .with_tonic() + .build() + .expect("build grpc log exporter failed") + } + + #[cfg(not(any(feature = "http", feature = "grpc")))] + compile_error!( + "You must enable either the 'http' or 'grpc' feature for the log exporter." + ); + }; + SdkLoggerProvider::builder() .with_resource(resource) - .install_batch(runtime::Tokio) - .expect("build LogProvider failed") + .with_batch_exporter(exporter) + .build() } fn init_metrics(resource: Resource) -> SdkMeterProvider { - let provider = opentelemetry_otlp::new_pipeline() - .metrics(runtime::Tokio) - .with_exporter(opentelemetry_otlp::new_exporter().tonic()) + let exporter = { + #[cfg(feature = "http")] + { + MetricExporter::builder() + .with_http() + .build() + .expect("build http metric exporter failed") + } + + #[cfg(all(not(feature = "http"), feature = "grpc"))] + { + MetricExporter::builder() + .with_tonic() + .build() + .expect("build grpc metric exporter failed") + } + + #[cfg(not(any(feature = "http", feature = "grpc")))] + compile_error!( + "You must enable either the 'http' or 'grpc' feature for the log exporter." + ); + }; + + let provider = SdkMeterProvider::builder() .with_resource(resource) - .build() - .expect("build MeterProvider failed"); + .with_periodic_exporter(exporter) + .build(); global::set_meter_provider(provider.clone()); tracing::debug!("metrics provider installed"); @@ -100,64 +147,77 @@ impl OpenTelemetryPlugin { provider } - fn init_tracer(resource: Resource) -> sdktrace::Tracer { - global::set_text_map_propagator(TraceContextPropagator::new()); + fn init_tracer(resource: Resource) -> SdkTracerProvider { + let exporter = { + #[cfg(feature = "http")] + { + SpanExporter::builder() + .with_http() + .build() + .expect("build http span exporter failed") + } + + #[cfg(all(not(feature = "http"), feature = "grpc"))] + { + SpanExporter::builder() + .with_tonic() + .build() + .expect("build grpc span exporter failed") + } + + #[cfg(not(any(feature = "http", feature = "grpc")))] + compile_error!( + "You must enable either the 'http' or 'grpc' feature for the log exporter." + ); + }; + + global::set_text_map_propagator(TextMapCompositePropagator::new(vec![ + Box::new(BaggagePropagator::new()), + Box::new(TraceContextPropagator::new()), + ])); #[cfg(feature = "jaeger")] global::set_text_map_propagator(opentelemetry_jaeger_propagator::Propagator::new()); #[cfg(feature = "zipkin")] global::set_text_map_propagator(opentelemetry_zipkin::Propagator::new()); - let provider = opentelemetry_otlp::new_pipeline() - .tracing() - .with_exporter(opentelemetry_otlp::new_exporter().tonic()) - .with_trace_config(sdktrace::Config::default().with_resource(resource)) - .with_batch_config(BatchConfig::default()) - .install_batch(runtime::Tokio) - .expect("build TraceProvider failed"); + let provider = SdkTracerProvider::builder() + .with_resource(resource) + .with_batch_exporter(exporter) + .build(); - let tracer = provider.tracer(env!("CARGO_PKG_NAME")); - global::set_tracer_provider(provider); + global::set_tracer_provider(provider.clone()); tracing::debug!("tracer provider installed"); - tracer - } - - fn get_resource_attr(env: Env) -> Resource { - Self::infra_resource().merge(&Self::app_resource(env)) - } - - fn app_resource(env: Env) -> Resource { - Resource::from_schema_url( - [KeyValue::new( - attribute::DEPLOYMENT_ENVIRONMENT_NAME, - format!("{:?}", env), - )], - opentelemetry_semantic_conventions::SCHEMA_URL, - ) + provider } - fn infra_resource() -> Resource { - Resource::from_detectors( - Duration::from_secs(0), - vec![ - #[cfg(feature = "more-resource")] + fn build_resource(app: &AppBuilder) -> Resource { + let mut key_values = app.get_component::().unwrap_or_default(); + key_values.push(KeyValue::new( + attribute::DEPLOYMENT_ENVIRONMENT_NAME, + format!("{:?}", app.get_env()), + )); + let mut builder = Resource::builder(); + #[cfg(feature = "more-resource")] + { + builder = builder.with_detectors(&[ Box::new(opentelemetry_resource_detectors::HostResourceDetector::default()), - #[cfg(feature = "more-resource")] Box::new(opentelemetry_resource_detectors::OsResourceDetector), - #[cfg(feature = "more-resource")] Box::new(opentelemetry_resource_detectors::ProcessResourceDetector), - Box::new(resource::SdkProvidedResourceDetector), - Box::new(resource::TelemetryResourceDetector), - Box::new(resource::EnvResourceDetector::new()), - ], - ) + ]); + } + builder = builder.with_attributes(key_values); + builder.build() } async fn shutdown( + tracer_provider: SdkTracerProvider, meter_provider: SdkMeterProvider, - log_provider: LoggerProvider, + log_provider: SdkLoggerProvider, ) -> Result { - global::shutdown_tracer_provider(); + tracer_provider + .shutdown() + .context("shutdown tracer provider failed")?; meter_provider .shutdown() .context("shutdown meter provider failed")?; @@ -171,28 +231,25 @@ impl OpenTelemetryPlugin { pub trait ResourceConfigurator { fn opentelemetry_attrs(&mut self, kvs: KV) -> &mut Self where - KV: IntoIterator, - { - self.merge_resource(Resource::from_schema_url( - kvs, - opentelemetry_semantic_conventions::SCHEMA_URL, - )) - } - - fn merge_resource(&mut self, resource: Resource) -> &mut Self; + KV: IntoIterator; } impl ResourceConfigurator for AppBuilder { - fn merge_resource(&mut self, resource: Resource) -> &mut Self { - if let Some(old_resource) = self.get_component_ref::() { + fn opentelemetry_attrs(&mut self, kvs: KV) -> &mut Self + where + KV: IntoIterator, + { + if let Some(key_values) = self.get_component_ref::() { unsafe { - let raw_ptr = ComponentRef::into_raw(old_resource) as *mut Resource; - let old_resource = &mut *(raw_ptr); - std::ptr::write(raw_ptr, old_resource.merge(&resource)); + let raw_ptr = ComponentRef::into_raw(key_values); + let key_values = &mut *(raw_ptr as *mut KeyValues); + key_values.extend(kvs); } self } else { - self.add_component(resource) + let mut key_values: KeyValues = vec![]; + key_values.extend(kvs); + self.add_component(key_values) } } } diff --git a/spring-opentelemetry/src/metrics.rs b/spring-opentelemetry/src/metrics.rs new file mode 100644 index 00000000..57dc7631 --- /dev/null +++ b/spring-opentelemetry/src/metrics.rs @@ -0,0 +1,312 @@ +//! Middleware that adds metrics to a [`Service`] that handles HTTP requests. +//! refs: https://opentelemetry.io/docs/specs/semconv/http/http-metrics/ + +use crate::util::http as http_util; +use http::{Request, Response}; +use http_body::Body; +use opentelemetry::{ + metrics::{Histogram, Meter, UpDownCounter}, + KeyValue, +}; +use opentelemetry_semantic_conventions::{ + attribute::{HTTP_REQUEST_METHOD, SERVER_ADDRESS}, + metric::{ + HTTP_CLIENT_ACTIVE_REQUESTS, HTTP_CLIENT_REQUEST_BODY_SIZE, HTTP_CLIENT_REQUEST_DURATION, + HTTP_CLIENT_RESPONSE_BODY_SIZE, HTTP_SERVER_ACTIVE_REQUESTS, HTTP_SERVER_REQUEST_BODY_SIZE, + HTTP_SERVER_REQUEST_DURATION, HTTP_SERVER_RESPONSE_BODY_SIZE, + }, + trace::{ + ERROR_TYPE, HTTP_RESPONSE_STATUS_CODE, HTTP_ROUTE, NETWORK_PROTOCOL_NAME, + NETWORK_PROTOCOL_VERSION, SERVER_PORT, + }, +}; +use pin_project::pin_project; +use std::{ + fmt::Display, + future::Future, + pin::Pin, + sync::Arc, + task::{ready, Context, Poll}, + time::Instant, +}; +use tower_layer::Layer; +use tower_service::Service; + +#[derive(Debug)] +struct MetricsRecord { + request_duration: Histogram, + active_requests: UpDownCounter, + request_body_size: Histogram, + response_body_size: Histogram, +} + +impl MetricsRecord { + fn server(meter: &Meter) -> Self { + Self { + request_duration: meter + .f64_histogram(HTTP_SERVER_REQUEST_DURATION) + .with_description("Duration of HTTP server requests") + .with_unit("s") + .with_boundaries(vec![ + 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1.0, 2.5, 5.0, 7.5, 10.0, + ]) + .build(), + active_requests: meter + .i64_up_down_counter(HTTP_SERVER_ACTIVE_REQUESTS) + .with_description("Number of active HTTP server requests") + .with_unit("{request}") + .build(), + request_body_size: meter + .u64_histogram(HTTP_SERVER_REQUEST_BODY_SIZE) + .with_description("Size of HTTP server request body") + .with_unit("By") + .build(), + response_body_size: meter + .u64_histogram(HTTP_SERVER_RESPONSE_BODY_SIZE) + .with_description("Size of HTTP server response body") + .with_unit("By") + .build(), + } + } + + fn client(meter: &Meter) -> Self { + Self { + request_duration: meter + .f64_histogram(HTTP_CLIENT_REQUEST_DURATION) + .with_description("Duration of HTTP client requests") + .with_unit("s") + .with_boundaries(vec![ + 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1.0, 2.5, 5.0, 7.5, 10.0, + ]) + .build(), + request_body_size: meter + .u64_histogram(HTTP_CLIENT_REQUEST_BODY_SIZE) + .with_description("Size of HTTP client request body") + .with_unit("By") + .build(), + response_body_size: meter + .u64_histogram(HTTP_CLIENT_RESPONSE_BODY_SIZE) + .with_description("Size of HTTP client response body") + .with_unit("By") + .build(), + active_requests: meter + .i64_up_down_counter(HTTP_CLIENT_ACTIVE_REQUESTS) + .with_description("Number of active HTTP client requests") + .with_unit("{request}") + .build(), + } + } +} + +/// [`Layer`] that adds tracing to a [`Service`] that handles HTTP requests. +#[derive(Clone, Debug)] +pub struct HttpLayer { + record: Arc, +} + +impl HttpLayer { + /// Metrics are recorded from server side. + pub fn server(meter: &Meter) -> Self { + let record = MetricsRecord::server(meter); + Self { + record: Arc::new(record), + } + } + + /// Metrics are recorded from client side. + pub fn client(meter: &Meter) -> Self { + let record = MetricsRecord::client(meter); + Self { + record: Arc::new(record), + } + } +} + +impl Layer for HttpLayer { + type Service = Http; + + fn layer(&self, inner: S) -> Self::Service { + Http { + inner, + record: Arc::clone(&self.record), + } + } +} + +/// Middleware that adds tracing to a [`Service`] that handles HTTP requests. +#[derive(Clone, Debug)] +pub struct Http { + inner: S, + record: Arc, +} + +impl Service> for Http +where + S: Service, Response = Response>, + S::Error: Display, + ReqBody: Body, + ResBody: Body, +{ + type Response = S::Response; + type Error = S::Error; + type Future = ResponseFuture; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: Request) -> Self::Future { + let state = ResponseMetricState::new(&req); + let record = Arc::clone(&self.record); + let inner = self.inner.call(req); + + record + .active_requests + .add(1, state.active_requests_attributes()); + + ResponseFuture { + inner, + record, + state, + } + } +} + +/// Response future for [`Http`]. +#[pin_project] +pub struct ResponseFuture { + #[pin] + inner: F, + record: Arc, + state: ResponseMetricState, +} + +impl Future for ResponseFuture +where + F: Future, E>>, + ResBody: Body, + E: Display, +{ + type Output = Result, E>; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.project(); + + let inner_response = ready!(this.inner.poll(cx)); + let duration = this.state.elapsed_seconds(); + + this.state.push_response_attributes(&inner_response); + + this.record + .request_duration + .record(duration, this.state.attributes()); + + this.record + .active_requests + .add(-1, this.state.active_requests_attributes()); + + if let Some(request_body_size) = this.state.request_body_size { + this.record + .request_body_size + .record(request_body_size, this.state.attributes()); + } + + if let Ok(response) = inner_response.as_ref() { + if let Some(response_size) = http_util::http_response_size(response) { + this.record + .response_body_size + .record(response_size, this.state.attributes()); + } + } + + Poll::Ready(inner_response) + } +} + +struct ResponseMetricState { + start: Instant, + /// The size of the request body. + request_body_size: Option, + /// Attributes to add to the metrics. + attributes: Vec, + /// The number of attributes that are used for only for active requests counter. + active_requests_attributes: usize, +} + +impl ResponseMetricState { + fn new(req: &Request) -> Self { + let start = Instant::now(); + + let request_body_size = http_util::http_request_size(req); + + let active_requests_attributes; + let attributes = { + let mut attributes = vec![]; + + let http_method = http_util::http_method(req.method()); + attributes.push(KeyValue::new(HTTP_REQUEST_METHOD, http_method)); + + if let Some(server_address) = req.uri().host() { + attributes.push(KeyValue::new(SERVER_ADDRESS, server_address.to_string())); + } + + if let Some(server_port) = req.uri().port_u16() { + attributes.push(KeyValue::new(SERVER_PORT, server_port as i64)); + } + + active_requests_attributes = attributes.len(); + + attributes.push(KeyValue::new(NETWORK_PROTOCOL_NAME, "http")); + + if let Some(http_version) = http_util::http_version(req.version()) { + attributes.push(KeyValue::new(NETWORK_PROTOCOL_VERSION, http_version)); + } + + if let Some(http_route) = http_util::http_route(req) { + attributes.push(KeyValue::new(HTTP_ROUTE, http_route.to_string())); + } + + attributes + }; + + Self { + start, + request_body_size, + attributes, + active_requests_attributes, + } + } + + fn push_response_attributes(&mut self, res: &Result, E>) + where + E: Display, + { + match res { + Ok(response) => { + self.attributes.push(KeyValue::new( + HTTP_RESPONSE_STATUS_CODE, + response.status().as_u16() as i64, + )); + } + Err(err) => { + self.attributes + .push(KeyValue::new(ERROR_TYPE, err.to_string())); + } + } + } + + /// Returns the elapsed time since the request was created in seconds. + fn elapsed_seconds(&self) -> f64 { + self.start.elapsed().as_secs_f64() + } + + /// Return the attributes for each metric. + fn attributes(&self) -> &[KeyValue] { + &self.attributes[..] + } + + /// Returns the attributes used for active requests counter. + fn active_requests_attributes(&self) -> &[KeyValue] { + &self.attributes[..self.active_requests_attributes] + } +} diff --git a/spring-opentelemetry/src/middlewares/mod.rs b/spring-opentelemetry/src/middlewares/mod.rs deleted file mode 100644 index 407d67b9..00000000 --- a/spring-opentelemetry/src/middlewares/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod tracing; \ No newline at end of file diff --git a/spring-opentelemetry/src/trace.rs b/spring-opentelemetry/src/trace.rs new file mode 100644 index 00000000..e4ce0dc6 --- /dev/null +++ b/spring-opentelemetry/src/trace.rs @@ -0,0 +1,52 @@ +mod grpc; +mod http; + +pub use crate::trace::grpc::GrpcLayer; +pub use crate::trace::http::HttpLayer; + +use std::env::VarError; + +/// Describes the relationship between the [`Span`] and the service producing the span. +#[derive(Clone, Copy, Debug)] +enum SpanKind { + /// The span describes a request sent to some remote service. + Client, + /// The span describes the server-side handling of a request. + Server, +} + +impl SpanKind { + /// refs: https://opentelemetry.io/docs/zero-code/java/agent/instrumentation/http/ + fn capture_request_headers(&self) -> Vec { + let var = match self { + Self::Client => "OTEL_INSTRUMENTATION_HTTP_CLIENT_CAPTURE_REQUEST_HEADERS", + Self::Server => "OTEL_INSTRUMENTATION_HTTP_SERVER_CAPTURE_REQUEST_HEADERS", + }; + Self::split_env_headers_value(var) + } + + /// refs: https://opentelemetry.io/docs/zero-code/java/agent/instrumentation/http/ + fn capture_response_headers(&self) -> Vec { + let var = match self { + Self::Client => "OTEL_INSTRUMENTATION_HTTP_CLIENT_CAPTURE_RESPONSE_HEADERS", + Self::Server => "OTEL_INSTRUMENTATION_HTTP_SERVER_CAPTURE_RESPONSE_HEADERS", + }; + Self::split_env_headers_value(var) + } + + #[inline] + fn split_env_headers_value(var: &str) -> Vec { + match std::env::var(var) { + Ok(headers) => headers + .split(",") + .map(|s| s.trim().to_lowercase()) + .collect(), + Err(e) => { + if let VarError::NotUnicode(value) = e { + tracing::warn!("{var} contains invalid unicode data:{value:?}") + } + vec![] + } + } + } +} diff --git a/spring-opentelemetry/src/trace/grpc.rs b/spring-opentelemetry/src/trace/grpc.rs new file mode 100644 index 00000000..bd90714b --- /dev/null +++ b/spring-opentelemetry/src/trace/grpc.rs @@ -0,0 +1,235 @@ +//! Middleware that adds tracing to a [`Service`] that handles gRPC requests. +//! refs: https://opentelemetry.io/docs/specs/semconv/rpc/grpc/ + +use super::SpanKind; +use http::{Request, Response}; +use opentelemetry_http::{HeaderExtractor, HeaderInjector}; +use opentelemetry_semantic_conventions::attribute::{ + EXCEPTION_MESSAGE, OTEL_STATUS_CODE, RPC_GRPC_STATUS_CODE, +}; +use pin_project::pin_project; +use std::{ + fmt::Display, + future::Future, + pin::Pin, + task::{ready, Context, Poll}, +}; +use tower::{Layer, Service}; +use tracing::{Level, Span}; +use tracing_opentelemetry::OpenTelemetrySpanExt; + +/// [`Layer`] that adds tracing to a [`Service`] that handles gRPC requests. +#[derive(Clone, Debug)] +pub struct GrpcLayer { + level: Level, + kind: SpanKind, +} + +impl GrpcLayer { + /// [`Span`]s are constructed at the given level from server side. + pub fn server(level: Level) -> Self { + Self { + level, + kind: SpanKind::Server, + } + } + + /// [`Span`]s are constructed at the given level from client side. + pub fn client(level: Level) -> Self { + Self { + level, + kind: SpanKind::Client, + } + } +} + +impl Layer for GrpcLayer { + type Service = GrpcService; + + fn layer(&self, inner: S) -> Self::Service { + GrpcService { + inner, + level: self.level, + kind: self.kind, + } + } +} + +/// Middleware that adds tracing to a [`Service`] that handles gRPC requests. +#[derive(Clone, Debug)] +pub struct GrpcService { + inner: S, + level: Level, + kind: SpanKind, +} + +impl Service> for GrpcService +where + S: Service, Response = Response>, + S::Error: Display, +{ + type Response = S::Response; + type Error = S::Error; + type Future = ResponseFuture; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, mut req: Request) -> Self::Future { + let span = self.make_request_span(&mut req); + let inner = { + let _enter = span.enter(); + self.inner.call(req) + }; + + ResponseFuture { + inner, + span, + kind: self.kind, + } + } +} + +impl GrpcService { + /// Creates a new [`Span`] for the given request. + fn make_request_span(&self, request: &mut Request) -> Span { + let Self { level, kind, .. } = self; + + macro_rules! make_span { + ($level:expr) => {{ + use tracing::field::Empty; + + tracing::span!( + $level, + "GRPC", + "exception.message" = Empty, + "otel.kind" = tracing::field::debug(kind), + "otel.name" = Empty, + "otel.status_code" = Empty, + "rpc.grpc.status_code" = Empty, + "rpc.method" = Empty, + "rpc.service" = Empty, + "rpc.system" = "grpc", + ) + }}; + } + + let span = match *level { + Level::ERROR => make_span!(Level::ERROR), + Level::WARN => make_span!(Level::WARN), + Level::INFO => make_span!(Level::INFO), + Level::DEBUG => make_span!(Level::DEBUG), + Level::TRACE => make_span!(Level::TRACE), + }; + + let path = request.uri().path(); + let name = path.trim_start_matches('/'); + span.record("otel.name", name); + if let Some((service, method)) = name.split_once('/') { + span.record("rpc.service", service); + span.record("rpc.method", method); + } + + let capture_request_headers = kind.capture_request_headers(); + + for (header_name, header_value) in request.headers().iter() { + let header_name = header_name.as_str().to_lowercase(); + if capture_request_headers.contains(&header_name) { + if let Ok(attribute_value) = header_value.to_str() { + // attribute::RPC_GRPC_REQUEST_METADATA + let attribute_name = format!("rpc.grpc.request.metadata.{}", header_name); + span.set_attribute(attribute_name, attribute_value.to_owned()); + } + } + } + + match kind { + SpanKind::Client => { + let context = span.context(); + opentelemetry::global::get_text_map_propagator(|injector| { + injector.inject_context(&context, &mut HeaderInjector(request.headers_mut())); + }); + } + SpanKind::Server => { + let context = opentelemetry::global::get_text_map_propagator(|extractor| { + extractor.extract(&HeaderExtractor(request.headers())) + }); + span.set_parent(context); + } + } + + span + } +} + +/// Response future for [`GrpcService`]. +#[pin_project] +pub struct ResponseFuture { + #[pin] + inner: F, + span: Span, + kind: SpanKind, +} + +impl Future for ResponseFuture +where + F: Future, E>>, + E: Display, +{ + type Output = Result, E>; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.project(); + let _enter = this.span.enter(); + + match ready!(this.inner.poll(cx)) { + Ok(response) => { + Self::record_response(this.span, *this.kind, &response); + Poll::Ready(Ok(response)) + } + Err(err) => { + Self::record_error(this.span, &err); + Poll::Ready(Err(err)) + } + } + } +} + +impl ResponseFuture +where + F: Future, E>>, + E: Display, +{ + /// Records fields associated to the response. + fn record_response(span: &Span, kind: SpanKind, response: &Response) { + let capture_response_headers = kind.capture_response_headers(); + + for (header_name, header_value) in response.headers().iter() { + let header_name = header_name.as_str().to_lowercase(); + if capture_response_headers.contains(&header_name) { + if let Ok(attribute_value) = header_value.to_str() { + let attribute_name: String = + format!("rpc.grpc.response.metadata.{}", header_name); + span.set_attribute(attribute_name, attribute_value.to_owned()); + } + } + } + + if let Some(header_value) = response.headers().get("grpc-status") { + if let Ok(header_value) = header_value.to_str() { + if let Ok(status_code) = header_value.parse::() { + span.record(RPC_GRPC_STATUS_CODE, status_code); + } + } + } else { + span.record(RPC_GRPC_STATUS_CODE, 0); + } + } + + /// Records the error message. + fn record_error(span: &Span, err: &E) { + span.record(OTEL_STATUS_CODE, "ERROR"); + span.record(EXCEPTION_MESSAGE, err.to_string()); + } +} diff --git a/spring-opentelemetry/src/middlewares/tracing.rs b/spring-opentelemetry/src/trace/http.rs similarity index 68% rename from spring-opentelemetry/src/middlewares/tracing.rs rename to spring-opentelemetry/src/trace/http.rs index d1a9f6c8..504c89a8 100644 --- a/spring-opentelemetry/src/middlewares/tracing.rs +++ b/spring-opentelemetry/src/trace/http.rs @@ -1,8 +1,15 @@ //! Middleware that adds tracing to a [`Service`] that handles HTTP requests. +//! https://opentelemetry.io/docs/specs/semconv/http/http-spans/ -use http::{Request, Response}; +use super::SpanKind; +use crate::util::http as http_util; +use http::{HeaderName, HeaderValue, Request, Response}; +use opentelemetry::trace::TraceContextExt; use opentelemetry_http::{HeaderExtractor, HeaderInjector}; -use opentelemetry_semantic_conventions::attribute; +use opentelemetry_semantic_conventions::{ + attribute::{EXCEPTION_MESSAGE, HTTP_RESPONSE_STATUS_CODE, OTEL_STATUS_CODE}, + trace::HTTP_ROUTE, +}; use pin_project::pin_project; use std::{ fmt::Display, @@ -14,21 +21,12 @@ use tower::{Layer, Service}; use tracing::{Level, Span}; use tracing_opentelemetry::OpenTelemetrySpanExt; -/// Describes the relationship between the [`Span`] and the service producing the span. -#[derive(Clone, Copy, Debug)] -enum SpanKind { - /// The span describes a request sent to some remote service. - Client, - /// The span describes the server-side handling of a request. - Server, -} - /// [`Layer`] that adds tracing to a [`Service`] that handles HTTP requests. #[derive(Clone, Debug)] pub struct HttpLayer { level: Level, kind: SpanKind, - with_headers: bool, + export_trace_id: bool, } impl HttpLayer { @@ -37,7 +35,7 @@ impl HttpLayer { Self { level, kind: SpanKind::Server, - with_headers: true, + export_trace_id: false, } } @@ -46,12 +44,12 @@ impl HttpLayer { Self { level, kind: SpanKind::Client, - with_headers: true, + export_trace_id: false, } } - pub fn with_headers(mut self, with_headers: bool) -> Self { - self.with_headers = with_headers; + pub fn export_trace_id(mut self, export_trace_id: bool) -> Self { + self.export_trace_id = export_trace_id; self } } @@ -64,7 +62,7 @@ impl Layer for HttpLayer { inner, level: self.level, kind: self.kind, - with_headers: self.with_headers, + export_trace_id: self.export_trace_id, } } } @@ -75,7 +73,7 @@ pub struct HttpService { inner: S, level: Level, kind: SpanKind, - with_headers: bool, + export_trace_id: bool, } impl Service> for HttpService @@ -102,7 +100,7 @@ where inner, span, kind: self.kind, - with_headers: self.with_headers, + export_trace_id: self.export_trace_id, } } } @@ -112,6 +110,7 @@ impl HttpService { fn make_request_span(&self, request: &mut Request) -> Span { let Self { level, kind, .. } = self; + // attribute::HTTP_REQUEST_METHOD macro_rules! make_span { ($level:expr) => {{ use tracing::field::Empty; @@ -128,7 +127,7 @@ impl HttpService { "otel.status_code" = Empty, "url.full" = tracing::field::display(request.uri()), "url.path" = request.uri().path(), - "url.query" = Empty, + "url.query" = request.uri().query(), ) }}; } @@ -141,8 +140,11 @@ impl HttpService { Level::TRACE => make_span!(Level::TRACE), }; - if self.with_headers { - for (header_name, header_value) in request.headers().iter() { + let capture_request_headers = kind.capture_request_headers(); + + for (header_name, header_value) in request.headers().iter() { + let header_name = header_name.as_str().to_lowercase(); + if capture_request_headers.contains(&header_name) { if let Ok(attribute_value) = header_value.to_str() { // attribute::HTTP_REQUEST_HEADER let attribute_name = format!("http.request.header.{}", header_name); @@ -151,10 +153,6 @@ impl HttpService { } } - if let Some(query) = request.uri().query() { - span.record(attribute::URL_QUERY, query); - } - match kind { SpanKind::Client => { let context = span.context(); @@ -163,6 +161,9 @@ impl HttpService { }); } SpanKind::Server => { + if let Some(http_route) = http_util::http_route(request) { + span.record(HTTP_ROUTE, http_route); + } let context = opentelemetry::global::get_text_map_propagator(|extractor| { extractor.extract(&HeaderExtractor(request.headers())) }); @@ -181,7 +182,7 @@ pub struct ResponseFuture { inner: F, span: Span, kind: SpanKind, - with_headers: bool, + export_trace_id: bool, } impl Future for ResponseFuture @@ -196,8 +197,22 @@ where let _enter = this.span.enter(); match ready!(this.inner.poll(cx)) { - Ok(response) => { - Self::record_response(this.span, *this.kind, *this.with_headers, &response); + Ok(mut response) => { + Self::record_response(this.span, *this.kind, &response); + if *this.export_trace_id { + let trace_id = this + .span + .context() + .span() + .span_context() + .trace_id() + .to_string(); + if let Ok(value) = HeaderValue::from_str(&trace_id) { + response + .headers_mut() + .insert(HeaderName::from_static("x-trace-id"), value); + } + } Poll::Ready(Ok(response)) } Err(err) => { @@ -214,16 +229,16 @@ where E: Display, { /// Records fields associated to the response. - fn record_response(span: &Span, kind: SpanKind, with_headers: bool, response: &Response) { - span.record( - attribute::HTTP_RESPONSE_STATUS_CODE, - response.status().as_u16() as i64, - ); - - if with_headers { - for (header_name, header_value) in response.headers().iter() { + fn record_response(span: &Span, kind: SpanKind, response: &Response) { + span.record(HTTP_RESPONSE_STATUS_CODE, response.status().as_u16() as i64); + + let capture_response_headers = kind.capture_response_headers(); + + for (header_name, header_value) in response.headers().iter() { + let header_name = header_name.as_str().to_lowercase(); + if capture_response_headers.contains(&header_name) { if let Ok(attribute_value) = header_value.to_str() { - let attribute_name = format!("http.response.header.{}", header_name); + let attribute_name: String = format!("http.response.header.{}", header_name); span.set_attribute(attribute_name, attribute_value.to_owned()); } } @@ -231,17 +246,17 @@ where if let SpanKind::Client = kind { if response.status().is_client_error() { - span.record(attribute::OTEL_STATUS_CODE, "ERROR"); + span.record(OTEL_STATUS_CODE, "ERROR"); } } if response.status().is_server_error() { - span.record(attribute::OTEL_STATUS_CODE, "ERROR"); + span.record(OTEL_STATUS_CODE, "ERROR"); } } /// Records the error message. fn record_error(span: &Span, err: &E) { - span.record(attribute::OTEL_STATUS_CODE, "ERROR"); - span.record(attribute::EXCEPTION_MESSAGE, err.to_string()); + span.record(OTEL_STATUS_CODE, "ERROR"); + span.record(EXCEPTION_MESSAGE, err.to_string()); } } diff --git a/spring-opentelemetry/src/util/http.rs b/spring-opentelemetry/src/util/http.rs new file mode 100644 index 00000000..7550a963 --- /dev/null +++ b/spring-opentelemetry/src/util/http.rs @@ -0,0 +1,53 @@ +use http::{Method, Request, Version}; +use http_body::Body; + +/// String representation of HTTP method +pub fn http_method(method: &Method) -> &'static str { + match *method { + Method::GET => "GET", + Method::POST => "POST", + Method::PUT => "PUT", + Method::DELETE => "DELETE", + Method::HEAD => "HEAD", + Method::OPTIONS => "OPTIONS", + Method::CONNECT => "CONNECT", + Method::PATCH => "PATCH", + Method::TRACE => "TRACE", + _ => "_OTHER", + } +} + +/// String representation of network protocol version +pub fn http_version(version: Version) -> Option<&'static str> { + match version { + Version::HTTP_09 => Some("0.9"), + Version::HTTP_10 => Some("1.0"), + Version::HTTP_11 => Some("1.1"), + Version::HTTP_2 => Some("2"), + Version::HTTP_3 => Some("3"), + _ => None, + } +} + +/// Get the size of the HTTP request body from the `Content-Length` header. +pub fn http_request_size(req: &Request) -> Option { + req.headers() + .get(http::header::CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse().ok()) + .or_else(|| req.body().size_hint().exact()) +} + +/// Get the size of the HTTP response body from the `Content-Length` header. +pub fn http_response_size(res: &http::Response) -> Option { + res.headers() + .get(http::header::CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse().ok()) + .or_else(|| res.body().size_hint().exact()) +} + +pub fn http_route(req: &http::Request) -> Option<&str> { + use axum::extract::MatchedPath; + req.extensions().get::().map(|matched_path| matched_path.as_str()) +} \ No newline at end of file diff --git a/spring-opentelemetry/src/util/mod.rs b/spring-opentelemetry/src/util/mod.rs new file mode 100644 index 00000000..e05256f7 --- /dev/null +++ b/spring-opentelemetry/src/util/mod.rs @@ -0,0 +1 @@ +pub mod http; \ No newline at end of file diff --git a/spring-postgres/CHANGELOG.md b/spring-postgres/CHANGELOG.md index 5fa96b1c..3e9257c8 100644 --- a/spring-postgres/CHANGELOG.md +++ b/spring-postgres/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.4.0 + +- **breaking**: upgrade `spring` 0.3 to 0.4 ([#132]) + +[#132]: https://github.com/spring-rs/spring-rs/pull/132 + ## 0.3.0 - **breaking**: refactor dependency inject ([#105]) diff --git a/spring-postgres/Cargo.toml b/spring-postgres/Cargo.toml index 2d46f02c..4ea83df2 100644 --- a/spring-postgres/Cargo.toml +++ b/spring-postgres/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "spring-postgres" description = "Integrate tokio-postgres with spring-rs" -version = "0.3.0" +version = "0.4.0" categories = ["database"] edition.workspace = true license.workspace = true @@ -26,7 +26,7 @@ with-uuid-0_8 = ["tokio-postgres/with-uuid-0_8"] with-uuid-1 = ["tokio-postgres/with-uuid-1"] [dependencies] -spring = { path = "../spring", version = "0.3" } +spring = { path = "../spring", version = "0.4" } serde = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true, features = ["log"] } diff --git a/spring-redis/CHANGELOG.md b/spring-redis/CHANGELOG.md index 8930064a..5f4153a8 100644 --- a/spring-redis/CHANGELOG.md +++ b/spring-redis/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.4.0 + +- **breaking**: upgrade `spring` 0.3 to 0.4 ([#132]) + +[#132]: https://github.com/spring-rs/spring-rs/pull/132 + ## 0.3.0 - **breaking**: refactor dependency inject ([#105]) diff --git a/spring-redis/Cargo.toml b/spring-redis/Cargo.toml index af61a5ce..aa2328df 100644 --- a/spring-redis/Cargo.toml +++ b/spring-redis/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "spring-redis" description = "Integrate redis-rs with spring-rs" -version = "0.3.0" +version = "0.4.0" keywords = ["redis", "database", "spring"] edition.workspace = true license.workspace = true @@ -9,7 +9,7 @@ authors.workspace = true repository.workspace = true [dependencies] -spring = { path = "../spring", version = "0.3" } +spring = { path = "../spring", version = "0.4" } serde = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true, features = ["log"] } diff --git a/spring-sea-orm/CHANGELOG.md b/spring-sea-orm/CHANGELOG.md index 52bfb7ad..e2c58673 100644 --- a/spring-sea-orm/CHANGELOG.md +++ b/spring-sea-orm/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.4.0 + +- **breaking**: upgrade `spring` 0.3 to 0.4 ([#132]) + +[#132]: https://github.com/spring-rs/spring-rs/pull/132 + +## 0.3.1 + +- **changed**: upgrade axum to 0.8 ([#122]) + +[#122]: https://github.com/spring-rs/spring-rs/pull/122 + ## 0.3.0 - **breaking**: refactor dependency inject ([#105]) diff --git a/spring-sea-orm/Cargo.toml b/spring-sea-orm/Cargo.toml index 47759cca..af9394d2 100644 --- a/spring-sea-orm/Cargo.toml +++ b/spring-sea-orm/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "spring-sea-orm" description = "Integration of spring-rs framework and sea-orm" -version = "0.3.0" +version = "0.4.0" categories = ["database"] keywords = ["orm", "sql-database", "spring"] edition.workspace = true @@ -17,8 +17,8 @@ postgres = ["sea-orm/sqlx-postgres"] with-web = ["spring-web"] [dependencies] -spring-web = { path = "../spring-web", version = "0.3", optional = true } -spring = { path = "../spring", version = "0.3" } +spring-web = { path = "../spring-web", version = "0.4", optional = true } +spring = { path = "../spring", version = "0.4" } serde = { workspace = true } anyhow = { workspace = true } thiserror = { workspace = true } diff --git a/spring-sea-orm/README.md b/spring-sea-orm/README.md index fef7fde5..b77cc11c 100644 --- a/spring-sea-orm/README.md +++ b/spring-sea-orm/README.md @@ -48,7 +48,7 @@ use spring_web::extractor::Component; use spring_web::error::Result; use anyhow::Context; -#[get("/:id")] +#[get("/{id}")] async fn get_todo_list( Component(db): Component, Path(id): Path diff --git a/spring-sea-orm/README.zh.md b/spring-sea-orm/README.zh.md index c5d344aa..9b3d05c2 100644 --- a/spring-sea-orm/README.zh.md +++ b/spring-sea-orm/README.zh.md @@ -48,7 +48,7 @@ use spring_web::extractor::Component; use spring_web::error::Result; use anyhow::Context; -#[get("/:id")] +#[get("/{id}")] async fn get_todo_list( Component(db): Component, Path(id): Path diff --git a/spring-sea-orm/src/pagination.rs b/spring-sea-orm/src/pagination.rs index 62003d37..3587fc7b 100644 --- a/spring-sea-orm/src/pagination.rs +++ b/spring-sea-orm/src/pagination.rs @@ -23,7 +23,6 @@ mod web { use super::Pagination; use crate::config::SeaOrmWebConfig; use serde::Deserialize; - use spring::async_trait; use spring_web::axum::extract::rejection::QueryRejection; use spring_web::axum::extract::{FromRequestParts, Query}; use spring_web::axum::http::request::Parts; @@ -56,8 +55,7 @@ mod web { size: Option, } - #[async_trait] - impl FromRequestParts for Pagination { + impl FromRequestParts for Pagination where S: Sync { type Rejection = SeaOrmWebErr; async fn from_request_parts( diff --git a/spring-sqlx/CHANGELOG.md b/spring-sqlx/CHANGELOG.md index a629a64a..41b13a2c 100644 --- a/spring-sqlx/CHANGELOG.md +++ b/spring-sqlx/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.4.0 + +- **breaking**: upgrade `spring` 0.3 to 0.4 ([#132]) + +[#132]: https://github.com/spring-rs/spring-rs/pull/132 + ## 0.3.0 - **breaking**: refactor dependency inject ([#105]) diff --git a/spring-sqlx/Cargo.toml b/spring-sqlx/Cargo.toml index 42cbf522..747db729 100644 --- a/spring-sqlx/Cargo.toml +++ b/spring-sqlx/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "spring-sqlx" description = "Integration of spring-rs framework and sqlx" -version = "0.3.0" +version = "0.4.0" categories = ["database"] keywords = ["sql-database", "sql-query", "database", "spring"] edition.workspace = true @@ -31,7 +31,7 @@ runtime-tokio-native-tls = ["sqlx?/runtime-tokio-native-tls", "runtime-tokio"] runtime-tokio-rustls = ["sqlx?/runtime-tokio-rustls", "runtime-tokio"] [dependencies] -spring = { path = "../spring", version = "0.3" } +spring = { path = "../spring", version = "0.4" } serde = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true, features = ["log"] } diff --git a/spring-stream/CHANGELOG.md b/spring-stream/CHANGELOG.md index 5fa96b1c..56fb6fba 100644 --- a/spring-stream/CHANGELOG.md +++ b/spring-stream/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.4.0 + +- **breaking**: upgrade `spring` and `spring-macros` 0.3 to 0.4 ([#132]) + +[#132]: https://github.com/spring-rs/spring-rs/pull/132 + ## 0.3.0 - **breaking**: refactor dependency inject ([#105]) diff --git a/spring-stream/Cargo.toml b/spring-stream/Cargo.toml index 3f597557..3cd5e635 100644 --- a/spring-stream/Cargo.toml +++ b/spring-stream/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "spring-stream" description = "Integrate sea-streamer with spring-rs" -version = "0.3.0" +version = "0.4.0" categories = ["concurrency"] keywords = ["stream-processing", "stream", "redis-stream", "kafka", "spring"] edition.workspace = true @@ -10,8 +10,8 @@ authors.workspace = true repository.workspace = true [dependencies] -spring = { path = "../spring", version = "0.3" } -spring-macros = { path = "../spring-macros", version = "0.3" } +spring = { path = "../spring", version = "0.4" } +spring-macros = { path = "../spring-macros", version = "0.4" } sea-streamer = { workspace = true } serde = { workspace = true } serde_json = { workspace = true, optional = true } diff --git a/spring-stream/src/config/mod.rs b/spring-stream/src/config/mod.rs index d3f604dc..0c86fa6a 100644 --- a/spring-stream/src/config/mod.rs +++ b/spring-stream/src/config/mod.rs @@ -59,10 +59,7 @@ impl StreamConfig { pub fn new_consumer_options(&self, ConsumerOpts(opts): ConsumerOpts) -> SeaConsumerOptions { let _mode = opts.mode().ok(); - let _group = match opts.consumer_group() { - Ok(group) => Some(group), - _ => None, - }; + let _group = opts.consumer_group().ok(); #[cfg(feature = "kafka")] if let Some(kafka) = &self.kafka { let mut consumer_options = kafka.new_consumer_options(_mode, _group); diff --git a/spring-web/CHANGELOG.md b/spring-web/CHANGELOG.md index 692071d0..afb8b6b9 100644 --- a/spring-web/CHANGELOG.md +++ b/spring-web/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 0.4.3 + +- **breaking**: upgrade `spring` and `spring-macros` 0.3 to 0.4 ([#132]) + +[#132]: https://github.com/spring-rs/spring-rs/pull/132 + +## 0.4.2 + +- **added**: add static_dir for fallback (#ccf3dd) + +[#ccf3dd]: https://github.com/spring-rs/spring-rs/commit/ccf3dd139cd9e67854940343163f027457ac2dc8 + +## 0.4.1 + +- **added**: fix *Nesting at the root is no longer supported. Use fallback_service instead* (#56805b) + +[#56805b]: https://github.com/spring-rs/spring-rs/commit/56805baea3de500287d0ef447ff48c28b095e4ba + +## 0.4.0 + +- **breaking**: upgrade axum to 0.8 ([#122]) + +[#122]: https://github.com/spring-rs/spring-rs/pull/122 + ## 0.3.0 - **breaking**: refactor dependency inject ([#105]) diff --git a/spring-web/Cargo.toml b/spring-web/Cargo.toml index e82d9000..6f8daa90 100644 --- a/spring-web/Cargo.toml +++ b/spring-web/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "spring-web" description = "Integration of rust application framework spring-rs and Axum web framework" -version = "0.3.0" +version = "0.4.3" categories = ["web-programming::http-server"] keywords = ["web-programming", "web-server", "spring"] edition.workspace = true @@ -16,8 +16,8 @@ multipart = ["axum/multipart"] ws = ["axum/ws"] [dependencies] -spring = { path = "../spring", version = "0.3" } -spring-macros = { path = "../spring-macros", version = "0.3" } +spring = { path = "../spring", version = "0.4" } +spring-macros = { path = "../spring-macros", version = "0.4" } axum = { workspace = true } serde = { workspace = true } anyhow = { workspace = true } diff --git a/spring-web/README.md b/spring-web/README.md index 37170c1a..e9ce7a61 100644 --- a/spring-web/README.md +++ b/spring-web/README.md @@ -15,7 +15,7 @@ optional **features**: `http2`, `multipart`, `ws`. ```toml [web] -binding = "172.20.10.4" # IP address of the network card to bind, default 0.0.0.0 +binding = "172.20.10.4" # IP address of the network interface to bind, default 0.0.0.0 port = 8000 # Port number to bind, default 8080 connect_info = false # Whether to use client connection information, default false graceful = true # Whether to enable graceful shutdown, default false diff --git a/spring-web/src/extractor.rs b/spring-web/src/extractor.rs index 22cc2657..d390df78 100644 --- a/spring-web/src/extractor.rs +++ b/spring-web/src/extractor.rs @@ -3,7 +3,7 @@ pub use axum::extract::*; use crate::error::{Result, WebError}; use crate::AppState; use anyhow::Context; -use axum::{async_trait, http::request::Parts}; +use axum::http::request::Parts; use spring::config::{ConfigRegistry, Configurable}; use spring::plugin::ComponentRegistry; use std::ops::{Deref, DerefMut}; @@ -47,10 +47,10 @@ impl RequestPartsExt for Parts { /// Extract the components registered by the plugin from AppState pub struct Component(pub T); -#[async_trait] impl FromRequestParts for Component where T: Clone + Send + Sync + 'static, + S: Sync { type Rejection = WebError; @@ -77,10 +77,10 @@ pub struct Config(pub T) where T: serde::de::DeserializeOwned + Configurable; -#[async_trait] impl FromRequestParts for Config where T: serde::de::DeserializeOwned + Configurable, + S: Sync { type Rejection = WebError; diff --git a/spring-web/src/lib.rs b/spring-web/src/lib.rs index 3cf8963f..40cb2f3e 100644 --- a/spring-web/src/lib.rs +++ b/spring-web/src/lib.rs @@ -65,7 +65,7 @@ impl WebConfigurator for AppBuilder { if let Some(routers) = self.get_component_ref::() { unsafe { let raw_ptr = ComponentRef::into_raw(routers); - let routers = &mut *(raw_ptr as *mut Vec); + let routers = &mut *(raw_ptr as *mut Routers); routers.push(router); } self diff --git a/spring-web/src/middleware.rs b/spring-web/src/middleware.rs index eee30604..e8bdba5f 100644 --- a/spring-web/src/middleware.rs +++ b/spring-web/src/middleware.rs @@ -83,8 +83,8 @@ fn apply_static_dir(router: Router, static_assets: StaticAssetsMiddleware) -> Ro ); } - let serve_dir = - ServeDir::new(static_assets.path).not_found_service(ServeFile::new(static_assets.fallback)); + let fallback = ServeFile::new(format!("{}/{}", static_assets.path, static_assets.fallback)); + let serve_dir = ServeDir::new(static_assets.path).not_found_service(fallback); let service = if static_assets.precompressed { tracing::info!("[Middleware] Enable precompressed static assets"); @@ -93,7 +93,11 @@ fn apply_static_dir(router: Router, static_assets: StaticAssetsMiddleware) -> Ro serve_dir }; - router.nest_service(&static_assets.uri, service) + if static_assets.uri == "/" { + router.fallback_service(service) + } else { + router.nest_service(&static_assets.uri, service) + } } fn build_cors_middleware(cors: &CorsMiddleware) -> Result { diff --git a/spring/CHANGELOG.md b/spring/CHANGELOG.md index d270b9e8..b11e6e2c 100644 --- a/spring/CHANGELOG.md +++ b/spring/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## 0.4.0 + +- **breaking**: upgrade `spring-macros` 0.3 to 0.4 ([#132]) + +[#132]: https://github.com/spring-rs/spring-rs/pull/132 + +**Migrating from 0.3 to 0.4** + +```diff + #[derive(Clone, Service)] ++#[service(prototype = "build")] +-#[prototype = "build"] + struct UserService { + #[inject(component)] + db: ConnectPool, + #[inject(config)] + config: UserConfig, + } +``` + +## 0.3.1 + +- **breaking**: remove `ComponentRegistry::create_service` ([#112]) +- **added**: Added prototype service derived macro generation `build` function ([#112]) + +[#112]: https://github.com/spring-rs/spring-rs/pull/112 + ## 0.3.0 - **breaking**: refactor dependency inject ([#105]) diff --git a/spring/Cargo.toml b/spring/Cargo.toml index a0f179c4..bc569070 100644 --- a/spring/Cargo.toml +++ b/spring/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "spring" description = "Rust microservice framework like spring boot in java" -version = "0.3.0" +version = "0.4.0" categories = ["config"] keywords = ["framework", "spring"] edition.workspace = true @@ -10,7 +10,7 @@ authors.workspace = true repository.workspace = true [dependencies] -spring-macros = { path = "../spring-macros", version = "0.3" } +spring-macros = { path = "../spring-macros", version = "0.4" } anyhow = { workspace = true } thiserror = { workspace = true } serde = { workspace = true } @@ -18,6 +18,7 @@ log = { workspace = true } nu-ansi-term = { workspace = true } tracing = { workspace = true, features = ["log"] } tracing-appender = { workspace = true } +tracing-error = { workspace = true } tracing-subscriber = { workspace = true, features = [ "json", "env-filter", diff --git a/spring/DI.md b/spring/DI.md index 66c65b4a..fd293801 100644 --- a/spring/DI.md +++ b/spring/DI.md @@ -26,4 +26,6 @@ struct UserService { } ``` -For the complete code, see [`dependency-inject-example`](https://github.com/spring-rs/spring-rs/tree/master/examples/dependency-inject-example). \ No newline at end of file +For the complete code, see [`dependency-inject-example`](https://github.com/spring-rs/spring-rs/tree/master/examples/dependency-inject-example). + +> Service also supports grpc mode and can be used in conjunction with the [spring-grpc](https://spring-rs.github.io/docs/plugins/spring-grpc/) plug-in \ No newline at end of file diff --git a/spring/DI.zh.md b/spring/DI.zh.md index afb4c90a..68d43687 100644 --- a/spring/DI.zh.md +++ b/spring/DI.zh.md @@ -25,4 +25,6 @@ struct UserService { } ``` -完整代码参考[`dependency-inject-example`](https://github.com/spring-rs/spring-rs/tree/master/examples/dependency-inject-example)。 \ No newline at end of file +完整代码参考[`dependency-inject-example`](https://github.com/spring-rs/spring-rs/tree/master/examples/dependency-inject-example)。 + +> Service还支持grpc模式,可结合[spring-grpc](https://spring-rs.github.io/zh/docs/plugins/spring-grpc/)插件一起使用 \ No newline at end of file diff --git a/spring/README.md b/spring/Plugin.md similarity index 100% rename from spring/README.md rename to spring/Plugin.md diff --git a/spring/README.zh.md b/spring/Plugin.zh.md similarity index 96% rename from spring/README.zh.md rename to spring/Plugin.zh.md index d28ddc1b..327b8f69 100644 --- a/spring/README.zh.md +++ b/spring/Plugin.zh.md @@ -9,7 +9,7 @@ * 所有的配置都需要实现[`Configurable`](https://docs.rs/spring/latest/spring/config/trait.Configurable.html)特征。 * 所有的组件都需要实现[`Clone`](https://doc.rust-lang.org/std/clone/trait.Clone.html)特征。 -> 注意:为了避免对Component内大结构的进行深拷贝,推荐使用[newtype模式](https://effective-rust.com/newtype.html)通过`Arc`进行引用。 +> 注意:为了避免对Component内大结构体进行深拷贝,推荐使用[newtype模式](https://effective-rust.com/newtype.html)通过`Arc`进行引用。 ## 如何编写自己的插件 diff --git a/spring/src/app.rs b/spring/src/app.rs index 54f8680a..eb33194b 100644 --- a/spring/src/app.rs +++ b/spring/src/app.rs @@ -4,7 +4,6 @@ use crate::config::toml::TomlConfigRegistry; use crate::config::ConfigRegistry; use crate::log::{BoxLayer, LogPlugin}; use crate::plugin::component::ComponentRef; -use crate::plugin::service::Service; use crate::plugin::{service, ComponentRegistry, MutableComponentRegistry, Plugin}; use crate::{ error::Result, @@ -179,11 +178,11 @@ impl AppBuilder { Err(e) => { log::error!("{:?}", e); } - Ok(app) => App::set_global(app), + _ => { /* ignore */ } } } - async fn inner_run(&mut self) -> Result> { + async fn inner_run(&mut self) -> Result<()> { // 1. load toml config self.load_config_if_need()?; @@ -256,7 +255,7 @@ impl AppBuilder { self.plugin_registry = registry; } - async fn schedule(&mut self) -> Result> { + async fn schedule(&mut self) -> Result<()> { let app = self.build_app(); let schedulers = std::mem::take(&mut self.schedulers); @@ -279,18 +278,19 @@ impl AppBuilder { let result = Box::into_pin(hook(app.clone())).await?; log::info!("shutdown result: {result}"); } - Ok(app) + Ok(()) } fn build_app(&mut self) -> Arc { let components = std::mem::take(&mut self.components); - // let prototypes = std::mem::take(&mut self.prototypes); let config = std::mem::take(&mut self.config); - Arc::new(App { + let app = Arc::new(App { env: self.env, components, config, - }) + }); + App::set_global(app.clone()); + app } } @@ -354,13 +354,6 @@ macro_rules! impl_component_registry { let component_id = TypeId::of::(); self.components.contains_key(&component_id) } - - fn create_service(&self) -> Result - where - S: Service + Send + Sync, - { - S::build(self) - } } }; } @@ -385,3 +378,53 @@ impl MutableComponentRegistry for AppBuilder { self } } + +#[cfg(test)] +mod tests { + use crate::plugin::{ComponentRegistry, MutableComponentRegistry}; + use crate::App; + + #[tokio::test] + async fn test_component_registry() { + #[derive(Clone)] + struct UnitComponent; + + #[derive(Clone)] + struct TupleComponent(i32, i32); + + #[derive(Clone)] + struct StructComponent { + x: i32, + y: i32, + } + + #[derive(Clone)] + struct Point { + x: T, + y: T, + } + + let app = App::new() + .add_component(UnitComponent) + .add_component(TupleComponent(1, 2)) + .add_component(StructComponent { x: 3, y: 4 }) + .add_component(Point { x: 5i64, y: 6i64 }) + .build() + .await; + let app = app.expect("app build failed"); + + let _ = app.get_expect_component::(); + let t = app.get_expect_component::(); + assert_eq!(t.0, 1); + assert_eq!(t.1, 2); + let s = app.get_expect_component::(); + assert_eq!(s.x, 3); + assert_eq!(s.y, 4); + let p = app.get_expect_component::>(); + assert_eq!(p.x, 5); + assert_eq!(p.y, 6); + + let p = app.get_component::>(); + assert!(p.is_none()) + } +} diff --git a/spring/src/config/env.rs b/spring/src/config/env.rs index 768547de..df6e6b47 100644 --- a/spring/src/config/env.rs +++ b/spring/src/config/env.rs @@ -116,8 +116,8 @@ pub(crate) fn interpolate(template: &str) -> String { result } +#[cfg(test)] mod tests { - #[allow(unused_imports)] use super::Env; use crate::error::Result; use std::{fs, path::PathBuf}; diff --git a/spring/src/config/toml.rs b/spring/src/config/toml.rs index d549bcce..7e7c1f9b 100644 --- a/spring/src/config/toml.rs +++ b/spring/src/config/toml.rs @@ -102,7 +102,7 @@ impl FromStr for TomlConfigRegistry { } } -#[allow(unused_imports)] +#[cfg(test)] mod tests { use super::Env; use super::TomlConfigRegistry; diff --git a/spring/src/lib.rs b/spring/src/lib.rs index 471e9e47..c3be62e3 100644 --- a/spring/src/lib.rs +++ b/spring/src/lib.rs @@ -21,3 +21,4 @@ pub use app::App; pub use async_trait::async_trait; pub use spring_macros::auto_config; pub use tracing; +pub use tracing_error::SpanTrace; \ No newline at end of file diff --git a/spring/src/log/mod.rs b/spring/src/log/mod.rs index 8359e63e..a9c0081a 100644 --- a/spring/src/log/mod.rs +++ b/spring/src/log/mod.rs @@ -1,5 +1,4 @@ -#![doc = include_str!("../../README.md")] - +#![doc = include_str!("../../Log-Plugin.md")] mod config; use crate::app::AppBuilder; @@ -7,6 +6,7 @@ use crate::config::ConfigRegistry; use crate::plugin::Plugin; use config::{Format, LogLevel, LoggerConfig, TimeStyle, WithFields}; use nu_ansi_term::Color; +use tracing_error::ErrorLayer; use std::sync::OnceLock; use tracing_appender::non_blocking::WorkerGuard; use tracing_subscriber::filter::EnvFilter; @@ -60,6 +60,7 @@ impl Plugin for LogPlugin { tracing_subscriber::registry() .with(layers) .with(env_filter) + .with(ErrorLayer::default()) .init(); } diff --git a/spring/src/plugin/mod.rs b/spring/src/plugin/mod.rs index fb63a6ef..1ca8caa8 100644 --- a/spring/src/plugin/mod.rs +++ b/spring/src/plugin/mod.rs @@ -1,4 +1,4 @@ -#![doc = include_str!("../../README.md")] +#![doc = include_str!("../../Plugin.md")] /// Component definition pub mod component; @@ -8,7 +8,6 @@ use crate::error::Result; use crate::{app::AppBuilder, error::AppError}; use async_trait::async_trait; use component::ComponentRef; -use service::Service; use std::{ any::{self, Any}, ops::Deref, @@ -125,11 +124,6 @@ pub trait ComponentRegistry { fn has_component(&self) -> bool where T: Any + Send + Sync; - - /// Creating a prototype service - fn create_service(&self) -> Result - where - S: Service + Send + Sync; } /// Mutable Component Registry