Skip to content

Commit a507cf2

Browse files
authored
Merge pull request #630 from bangbaew/master
Middleware for caching succesful GET responses with Redis automatically
2 parents 632b133 + 1dd88c1 commit a507cf2

File tree

5 files changed

+239
-3
lines changed

5 files changed

+239
-3
lines changed

Cargo.lock

Lines changed: 14 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ members = [
1414
"basics/state",
1515
"basics/static-files",
1616
"basics/todo",
17+
"cache/redis",
1718
"cors/backend",
1819
"data-factory",
1920
"databases/diesel",

cache/redis/Cargo.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
[package]
2+
name = "cache-redis"
3+
version = "1.0.0"
4+
publish = false
5+
authors = ["Jatutep Bunupuradah <bangbaew@gmail.com>"]
6+
edition = "2024"
7+
8+
[dependencies]
9+
actix-web.workspace = true
10+
env_logger.workspace = true
11+
redis = { version = "0.27", features = ["tokio-native-tls-comp"] }

cache/redis/README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Redis Cache Middleware
2+
3+
This project demonstrates how to implement Redis cache middleware to handle cache responses synchronously.
4+
The application should be able to function properly even if Redis is not running; however, the caching process will be disabled in such cases.
5+
6+
7+
## Setting up
8+
Configure the environment variable `REDIS_HOST`, or if not set, the default host `redis://localhost:4379` will be used. TLS is supported using the `rediss://` protocol.
9+
Run the server using `cargo run`.
10+
11+
## Endpoints
12+
13+
### `GET /fibonacci/{number}`
14+
15+
To test the demo, send a GET request to `/fibonacci/{number}`, where {number} is a positive integer of type u64.
16+
17+
## Request Directives
18+
19+
- `Cache-Control: no-cache` will return the most up-to-date response while still caching it. This will always yield a `miss` cache status.
20+
- `Cache-Control: no-store` will prevent caching, ensuring you always receive the latest response.
21+
22+
## Verify Redis Contents
23+
24+
When making the first GET request to `/fibonacci/47`, it may take around 8 seconds to respond.
25+
If Redis is running and the connection is established, subsequent requests should return the cached result immediately, a `hit` cache status will be returned, but with content type `application/json`.
26+
27+
## Known issues
28+
29+
- Connecting to a remote Redis server might introduce significant overhead and could lead to prolonged connection times or even failure to reach the remote server.
30+
31+
## Further implementations
32+
33+
- Implement asynchronous insertion of cache responses.
34+
- Explore using an in-memory datastore within the application process to reduce reliance on Redis.

cache/redis/src/main.rs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
use std::env;
2+
3+
use actix_web::middleware::{Next, from_fn};
4+
use actix_web::{
5+
App, Error, HttpResponse, HttpServer, Responder,
6+
body::{self, MessageBody},
7+
dev::{ServiceRequest, ServiceResponse},
8+
http::{
9+
Method, StatusCode,
10+
header::{CACHE_CONTROL, CACHE_STATUS, CONTENT_TYPE, CacheDirective, HeaderValue},
11+
},
12+
middleware, web,
13+
};
14+
use redis::{Client as RedisClient, Commands, RedisError};
15+
16+
fn fib_recursive(n: u64) -> u64 {
17+
if n <= 1 {
18+
return n;
19+
}
20+
fib_recursive(n - 1) + fib_recursive(n - 2)
21+
}
22+
23+
async fn an_expensive_function(n: web::Path<u64>) -> impl Responder {
24+
let result = fib_recursive(n.to_owned());
25+
HttpResponse::Ok().body(result.to_string())
26+
}
27+
28+
#[actix_web::main]
29+
async fn main() -> std::result::Result<(), std::io::Error> {
30+
env_logger::init();
31+
32+
let redis_client =
33+
redis::Client::open(env::var("REDIS_HOST").unwrap_or("redis://localhost:6379".to_owned()))
34+
.unwrap();
35+
36+
let listen_port: String = env::var("LISTEN_PORT").unwrap_or(8080.to_string());
37+
let listen_address: String = ["0.0.0.0", &listen_port].join(":");
38+
39+
println!("Server is listening at {}...", listen_address);
40+
HttpServer::new(move || {
41+
App::new()
42+
.wrap(from_fn(cache_middleware))
43+
.app_data(redis_client.to_owned())
44+
.service(web::resource("/fibonacci/{n}").route(web::get().to(an_expensive_function)))
45+
.wrap(middleware::Logger::default())
46+
})
47+
.bind(listen_address)?
48+
.run()
49+
.await?;
50+
51+
Ok(())
52+
}
53+
54+
pub async fn cache_middleware(
55+
req: ServiceRequest,
56+
next: Next<impl MessageBody>,
57+
) -> Result<ServiceResponse<impl MessageBody>, Error> {
58+
// Adjust cache expiry here
59+
const MAX_AGE: u64 = 86400;
60+
let cache_max_age = format!("max-age={MAX_AGE}").parse::<HeaderValue>().unwrap();
61+
// Defining cache key based on request path and query string
62+
let key = if req.query_string().is_empty() {
63+
req.path().to_owned()
64+
} else {
65+
format!("{}?{}", req.path(), req.query_string())
66+
};
67+
println!("cache key: {key:?}");
68+
69+
// Get "Cache-Control" request header and get cache directive
70+
let headers = req.headers().to_owned();
71+
let cache_directive = match headers.get(CACHE_CONTROL) {
72+
Some(cache_control_header) => cache_control_header.to_str().unwrap_or(""),
73+
None => "",
74+
};
75+
76+
// If cache directive is not "no-cache" and not "no-store"
77+
if cache_directive != CacheDirective::NoCache.to_string()
78+
&& cache_directive != CacheDirective::NoStore.to_string()
79+
&& key != "/metrics"
80+
{
81+
// Initialize Redis Client from App Data
82+
let redis_client = req.app_data::<RedisClient>();
83+
// This should always be Some, so let's unwrap
84+
let mut redis_conn = redis_client.unwrap().get_connection();
85+
let redis_ok = redis_conn.is_ok();
86+
87+
// If Redis connection succeeded and request method is GET
88+
if redis_ok && req.method() == Method::GET {
89+
// Unwrap the connection
90+
let redis_conn = redis_conn.as_mut().unwrap();
91+
92+
// Try to get the cached response by defined key
93+
let cached_response: Result<Vec<u8>, RedisError> = redis_conn.get(key.to_owned());
94+
if let Err(e) = cached_response {
95+
// If cache cannot be deserialized
96+
println!("cache get error: {}", e);
97+
} else if cached_response.as_ref().unwrap().is_empty() {
98+
// If cache body is empty
99+
println!("cache not found");
100+
} else {
101+
// If cache is found
102+
println!("cache found");
103+
104+
// Prepare response body
105+
let res = HttpResponse::new(StatusCode::OK).set_body(cached_response.unwrap());
106+
let mut res = ServiceResponse::new(req.request().to_owned(), res);
107+
108+
// Define content-type and headers here
109+
res.headers_mut()
110+
.append(CONTENT_TYPE, HeaderValue::from_static("application/json"));
111+
res.headers_mut().append(CACHE_CONTROL, cache_max_age);
112+
res.headers_mut()
113+
.append(CACHE_STATUS, HeaderValue::from_static("hit"));
114+
115+
return Ok(res);
116+
}
117+
}
118+
}
119+
120+
// If Redis connection fails or cache could not be found
121+
// Call the next service
122+
let res = next.call(req).await?;
123+
124+
// deconstruct response into parts
125+
let (req, res) = res.into_parts();
126+
let (res, body) = res.into_parts();
127+
128+
// Convert body to Bytes
129+
let body = body::to_bytes(body).await.ok().unwrap();
130+
// Use bytes directly for caching instead of converting to a String
131+
let res_body_enc = body.to_vec();
132+
133+
// Prepare response body
134+
let res = res.set_body(res_body_enc.to_owned());
135+
let mut res = ServiceResponse::new(req.to_owned(), res);
136+
137+
// If a GET request succeeded and cache directive is not "no-store"
138+
if req.method() == Method::GET
139+
&& StatusCode::is_success(&res.status())
140+
&& cache_directive != CacheDirective::NoStore.to_string()
141+
&& key != "/metrics"
142+
{
143+
// Define response headers here
144+
res.headers_mut().append(CACHE_CONTROL, cache_max_age);
145+
res.headers_mut()
146+
.append(CACHE_STATUS, HeaderValue::from_static("miss"));
147+
148+
// Initialize Redis Client from App Data
149+
let redis_client = req.app_data::<RedisClient>();
150+
// This should always be Some, so let's unwrap
151+
let redis_conn = redis_client.unwrap().get_connection();
152+
let redis_ok = redis_conn.is_ok();
153+
154+
// If Redis connection succeeded
155+
if redis_ok {
156+
// Try to insert the response body into Redis
157+
let mut redis_conn = redis_conn.unwrap();
158+
let insert = redis::Cmd::set_ex(key, res_body_enc, MAX_AGE);
159+
// Or keep the cache forever:
160+
// let insert = redis::Cmd::set(key, res_body_enc);
161+
let insert = insert.query::<String>(&mut redis_conn);
162+
163+
if let Err(e) = insert {
164+
// If cache insertion failed
165+
println!("cache insert error: {}", e);
166+
} else {
167+
// This should print "cache insert success: OK"
168+
println!("cache insert success: {}", insert.unwrap());
169+
}
170+
} else if let Err(e) = redis_conn {
171+
// If Redis connection failed
172+
println!("RedisError: {}", e);
173+
}
174+
} else {
175+
// If the request method is not "GET" or the operation failed or cache directive is "no-store"
176+
println!("not inserting cache");
177+
}
178+
Ok(res)
179+
}

0 commit comments

Comments
 (0)