Is my code idiomatic and is the async code optimized?

Hello Rustaceans,

I recently published my first project in Rust and had a steep learning curve (got thorough experience in Java/Kotlin, Go). Now that my project is more or less finished (with some optional ToDos for the future) I'd really appreciate some feedback.

I'm especially unsure whether I correctly implemented the async code, if my code is idiomatic in Rust and if there are any obvious or glaring issues, as well as potential for optimization.

I already used rustfmt and clippy::all (as well as pedantic and nursery).

The application is a very small Prometheus Multi-Target Exporter that pings a specific Minecraft server and returns the details of said server: GitHub - scrayosnet/mcexport: Minecraft Server Prometheus Exporter

Thank you for taking the time to provide some feedback!

1 Like

It really shows! The structure of the code matches what I would expect to see for any project.

You have a huge number of imports in main.rs, which either suggests you need to break this file into smaller modules or you could get away with some minification on the list of imports. (FWIW, breaking it up is the right thing to do; try to keep main.rs as small as possible: really just handling CLI argument parsing and top-level error handling. Everything else should just be a single import from a library or one of the submodule that you call.)

I'm really not used to having all of the code be bundled into a single binary. I've always structured my bins as a tiny wrapper around a giant library. I probably got this idiom from stackoverflow originally. It is indispensible when you want to use most of the application code as a library. And integration testing requires a library, anyway.

For the minification effort, you can combine many lines. It isn't a huge improvement, but it's something. Nearly 10 lines shorter.

diff --git a/src/main.rs b/src/main.rs
index 20c6d41..7c91cf9 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -3,35 +3,24 @@ mod probe;
 mod protocol;
 
 use crate::ping::{get_server_status, ProbeStatus};
-use crate::probe::ProbingInfo;
 use crate::probe::ResolutionResult::{Plain, Srv};
-use crate::TimeoutError::NegativeDuration;
-use axum::body::Body;
+use crate::{probe::ProbingInfo, TimeoutError::NegativeDuration};
 use axum::extract::{Query, State};
 use axum::http::header::{ToStrError, CONTENT_TYPE};
 use axum::http::{HeaderMap, StatusCode};
 use axum::response::{Html, IntoResponse, Response};
-use axum::routing::get;
-use axum::Router;
+use axum::{body::Body, routing::get, Router};
 use hickory_resolver::TokioAsyncResolver;
-use prometheus_client::encoding::text::encode;
-use prometheus_client::metrics::family::Family;
-use prometheus_client::metrics::gauge::Gauge;
 use prometheus_client::registry::Registry;
+use prometheus_client::{encoding::text::encode, metrics::family::Family, metrics::gauge::Gauge};
 use std::collections::hash_map::DefaultHasher;
 use std::hash::{Hash, Hasher};
-use std::net::SocketAddr;
-use std::num::ParseFloatError;
 use std::sync::atomic::{AtomicU32, AtomicU64};
-use std::sync::Arc;
-use std::time::Duration;
-use tokio::net::TcpListener;
-use tokio::time::timeout;
+use std::{net::SocketAddr, num::ParseFloatError, sync::Arc, time::Duration};
+use tokio::{net::TcpListener, time::timeout};
 use tower_http::trace::TraceLayer;
 use tracing::{info, instrument, warn};
-use tracing_subscriber::filter::LevelFilter;
-use tracing_subscriber::layer::SubscriberExt;
-use tracing_subscriber::util::SubscriberInitExt;
+use tracing_subscriber::{filter::LevelFilter, layer::SubscriberExt, util::SubscriberInitExt};
 use tracing_subscriber::{fmt, Layer};
 
 /// The name of the request header from Prometheus, that sets the scrape timeout.

This will be handy anyway, even though you should really split this file into library modules.

You can also use -> impl IntoResponse so you don't have to do the conversion in your route's function body:

async fn handle_root() -> impl IntoResponse {
    Html("mcexport - <a href=\"/probe\">Probe</a>")
}

Or even specify the exact type name, -> Html<&'static str>

Rather than #[allow(clippy::cast_sign_loss)] in protocol.rs when creating the outbound handshake packet, I would change the field to usize and deal with errors during parsing (probably with unwrap() if the value is never allowed to be negative). Parse, don’t validate

Your CI could benefit from some additional checks:

  • cargo doc --no-deps to catch any documentation errors.
  • cargo machete --with-metadata to check for unused dependencies.
  • Add a build matrix with stable, beta and some MSRV pinned version to catch regressions caused by changes to the compiler. This is best combined with a monthly cron schedule.
  • Consider cargo deny for license violations and security vulnerabilities (similar to dependabot).

Nothing else really stands out as unusual or missing. I'm kind of scraping the bottom of the barrel here to come up with suggestions.

It doesn't look like there are any obvious issues in any of these categories. Optimizations are really hard to pinpoint without profiling. But I would look for suspicious allocations and copying, first. For instance, AsyncReadPacket::read_packet() -> StatusResponsePacket allocates a Vec<u8>, then converts it to a String in a kind of roundabout way, by allocating a second Vec<u8> in read_string().

You can read straight from the socket into a String for StatusResponsePacket. It requires some sort of "specialization" since the return type is generic and the two InboundPacket implementers have different inner types. Another option might be serde which can deserialize from Vec<u8> straight to your final type. You could also hand-write a TryFrom<&[u8]> impl for your types to avoid the intermediate allocations and copying without overspecializing.

2 Likes

First of all, thank you very much for your detailed answer! I really appreciate all the suggestions and references that you included in your post! I'll try to implement all of them and am happy that I can deepen my understanding of Rust this way!

I somehow thought that either rustfmt, clippy or Rust Rover would automatically optimize/compress my imports, but maybe I just didn't activate the right setting yet. Thanks for pointing that out, the end result definitely looks cleaner/more concise!

And I'll definitely consider breaking up my main.rs, so that it's only a minimal entry point and doesn't include any specific implementation/behaviour of the endpoints. Oh, I didn't know using integration tests require a library!

I changed this now, thanks! Iguess I didn't specify the exact type before, since I'm still a little bit confused with the lifetime specifiers. I totally get that they are important and are part of the borrow checker and I can already read and understand them, but not so much write/assign them yet, if that makes sense.

That's one of the things in the code, I was the most weary about! The original reason for the cast to usize is that write_varint(usize) accepts an unsigned number, because it is also used for packet ids, which really can never be negative. But the protocol number (even though its also encoded as a VarInt) can in fact be negative. Especially -1 is very common. So to fit the signed protocol number into the write_varint function, I just casted it.

Considering this, what would be the best way to change the code?

Those are all brilliant suggestions! I am a real fan of validation/linting/pipelines, so I'll definitely add all of them! I am really glad, that there is such excellent, easy-to-use tooling for Rust!

Thank you very much! I already felt that some of the steps I took there felt kind of cumbersome, but I couldn't exactly find ways to write that in a better/more concise way. I'll change this for sure!

Thanks again for taking the time to review my code and helping me getting more familiar with Rusts intricacies!

1 Like

Yup, common learning curve! 'static is the only special lifetime name (provided by the compiler) which is why you don't need a generic parameter to declare it on impl<...> or fn foo<...>(). It's is useful for anything that is statically allocated, like string literals.

All other lifetime annotations are just there to be given a name for the regions they cover and specify "subtyping" relations between them. Don't worry about it, you'll get used to it in time!

That makes sense. In that case, I think the cast is fine.

If the lint annotation is too annoying, you can do the cast explicitly:

let version_as_usize = usize::from_ne_bytes(self.protocol_version.to_ne_bytes());
1 Like