For years, I had persistent distrust of interpreted languages. They are fast and it is pleasant to work with them, but they are good only for work on small systems, if you have a fast-growing project their attractiveness quickly disappears. Creating a large application in Ruby or JavaScript (or many other languages) is an endless Sisyphean task — you solve one problem only so that the other immediately rolls down on you from the mountain. And it does not matter how many tests you write or how good your team is; any new development will create a myriad of errors, the correction of which will take months or years.
The main problem lies in the border conditions. Programmers do their best to write and test the “happy path”, but the human factor prevents us from seeing the problem from all sides and especially the edges and corners that cause the greatest problems while the program is being used.
Constraints, such as the compiler and the insightful type system, are the tools that help us define these conditions. All languages ​​have a range of solvability, and I am clearly convinced that the more time is spent writing an application according to the rules of the language, the less time will be spent on troubleshooting.
If it is possible to build reliable systems with programming languages ​​with restrictions, then what about languages ​​with the most stringent restrictions? I headed to the farthest end of the spectrum and created a highly unpopular web service on Rust for its uncompromising compiler.
Rust is still a new and relatively rarely used language. Studying it was not easy - the set of rules of the type system, and the rules of ownership and borrowing, but despite the difficulties, the experience was interesting, the main thing Rust works. I encounter fewer forgotten border conditions and runtime errors, and refactoring no longer causes horror.
Next we look at some of the ideas, core libraries, and structures of Rust.
I built my system on actix-web , an actix-built web framework, actor library for Rust. Actix is similar to what you might find in Erlang, for example, but it adds another level of reliability and speed using the Rust type and parallelism system. For example, it is impossible for an actor to receive a message that it cannot process at runtime, because the compiler will check the correspondence of the message types.
Perhaps you know the name actix - recently actix-web has made its way to the top of TechEmpower tests. Programs created for such tests are often artificially optimized, but now, among all optimized languages, Rust stands confidently, leaning as far as possible towards such giants as C ++ and Java. Regardless of how you feel about the accuracy of benchmarks, actix-web works quickly.
Rust in the top 10 with Java and C ++ in TechEmpower tests.
The author of actix-web (and actix) creates a huge amount of code - the project appeared about six months ago, and it is not only more functional, with better API interfaces than web frameworks in other open source languages, but moreover, more functional frameworks that are funded by large organizations with huge development teams. Features such as HTTP / 2, WebSockets, streaming responses, graceful shutdown, HTTPS, cookie support, static files serving and a good testing infrastructure are available immediately. The documentation is still a bit incomplete, but I have not yet encountered any errors.
I used diesel as ORM to talk to Postgres. ORM is written by a person with extensive experience who spent a lot of time on the front line working with Active Record. Many of the errors inherent in earlier generations of ORM were eliminated - for example, diesel
does not pretend that the SQL dialects in each database are the same, does not use specialized DSL for migration (instead, it uses normal SQL) and it does not manage connections to the database at the global level. It provides powerful Postgres features such as upsert
and jsonb
right in the main library and provides, if possible, powerful security mechanisms.
Most database queries are written using diesel DSL types. If I use the field incorrectly, I try to insert a tuple into the wrong table or even create an impossible join, the compiler will immediately give an error message. Here is a typical operation (in this case, Postgres INSERT INTO ... ON CONFLICT
... or "upsert"):
time_helpers::log_timed(&log.new(o!("step" => "upsert_episodes")), |_log| { Ok(diesel::insert_into(schema::episode::table) .values(ins_episodes) .on_conflict((schema::episode::podcast_id, schema::episode::guid)) .do_update() .set(( schema::episode::description.eq(excluded(schema::episode::description)), schema::episode::explicit.eq(excluded(schema::episode::explicit)), schema::episode::link_url.eq(excluded(schema::episode::link_url)), schema::episode::media_type.eq(excluded(schema::episode::media_type)), schema::episode::media_url.eq(excluded(schema::episode::media_url)), schema::episode::podcast_id.eq(excluded(schema::episode::podcast_id)), schema::episode::published_at.eq(excluded(schema::episode::published_at)), schema::episode::title.eq(excluded(schema::episode::title)), )) .get_results(self.conn) .chain_err(|| "Error upserting podcast episodes")?) })
More complex SQL is difficult to create using DSL, but, fortunately, there is a great alternative to the built-in include_str! macro. It includes the contents of the file at compile time, and we can transfer them to diesel for binding and filling with parameters:
diesel::sql_query(include_str!("../sql/cleaner_directory_search.sql")) .bind::<Text, _>(DIRECTORY_SEARCH_DELETE_HORIZON) .bind::<BigInt, _>(DELETE_LIMIT) .get_result::<DeleteResults>(conn) .chain_err(|| "Error deleting directory search content batch")
The request is in its own .sql file:
WITH expired AS ( SELECT id FROM directory_search WHERE retrieved_at < NOW() - $1::interval LIMIT $2 ), deleted_batch AS ( DELETE FROM directory_search WHERE id IN ( SELECT id FROM expired ) RETURNING id ) SELECT COUNT(*) FROM deleted_batch;
We cannot do SQL validation at compile time with this approach, but on the other hand we have direct access to the original SQL syntax and excellent syntax highlighting in your favorite editor.
actix-web
runs on top of tokio , a fast asynchronous event processing library that is the cornerstone of Rust's asynchronous operation. When you start the HTTP server, actix-web
creates a certain number of worker threads equal to the number of logical cores on the server, each in its own system thread and with its own tokio
reactor.
HTTP request handlers can be written in various ways. For example, a handler synchronously returning data:
fn index(req: HttpRequest) -> Bytes { ... }
This handler blocks the tokio
reactor until it returns a result, which is suitable in situations where no additional blocking calls are required. For example, rendering static content from memory or responding to an application status check.
You can also write a handler that returns future
. This will allow us to combine a series of asynchronous calls to ensure that the reactor is never blocked.
fn index(req: HttpRequest) -> Box<Future<Item=HttpResponse, Error=Error>> { ... }
Examples of this might be an operation with a file that we read from disk (blocking I / O, albeit minimally), or waiting for a response from our database. Waiting for the future, the tokio
reactor will handle other requests.
An example of a concurrency model with actix-web.
futures
support in Rust
is widespread, but not universal. It is noteworthy that diesel does not support asynchronous operations, so all its operations will be blocked.
When using diesel
, directly from the actix-web
processor, block the tokio
reactor and stop processing requests until the blocking operation is completed.
Fortunately, actix
has an excellent solution to this problem in the form of synchronous actors. Actors perform synchronous processing of messages during operation and therefore each is assigned its own dedicated OS thread. SyncArbiter
makes it easy to run multiple copies of an actor of the same type, each of which works with a common message queue, which makes it possible to work with all actors simultaneously (see below as addr
):
// Start 3 `DbExecutor` actors, each with its own database // connection, and each in its own thread let addr = SyncArbiter::start(3, || { DbExecutor(SqliteConnection::establish("test.db").unwrap()) });
Although operations inside the synchronous actor are blocked by other actors in the system, such as HTTP handlers, it is not necessary to wait for the completion of any of them — they receive a future
that represents the result of the message while they do other work.
In my implementation, fast computations, such as processing request parameters and rendering content, are performed inside handlers, and synchronous actors are never activated unless they are needed. When the response requires database operations, the message is sent to the synchronous actor, and the tokio
reactor serves other traffic, waiting for the future to be completed. When this happens, it creates an http response with the results and sends it back to the waiting client.
At first glance, the introduction of synchronous actors into the system may seem a disadvantage, since they limit the parallelism of the system. However, these limitations may also be an advantage. One of the first scaling issues you are likely to encounter in Postgres is the limit on the maximum number of simultaneous connections. Even the largest bases on Heroku or GCP (Cloud Cloud Platform) give a maximum of 500 connections, and in smaller bases the limitations are even lower (my small base on the GCP has restrictions of 25 connections). Large systems using framework connection functions (for example, Rails and many others) use such solutions as PgBouncer to solve this problem.
Specifying the number of synchronous actors by default also implies the maximum number of connections that the service will use, which leads to perfect control over the use of connections.
Connections are used only if a synchronous actor is required.
I wrote my synchronous actors to use separate connections from the connection pool ( r2d2 ) only when the work begins and release them after completion. When the service is in standby mode, starting or shutting down, it does not use connections. Compare this with many web frameworks, where the system opens a connection to the database as soon as the workflow has started and keeps it open until the workflow stops. This approach requires ~ 2x connections for elegant restarts, because all workflows establish a connection and keep it even during completion.
Synchronous operations are not performed as quickly as a purely asynchronous approach, but their advantage is ease of use. It's nice that futures
are fast, but writing them accordingly takes a lot of time, and the compiler errors they generate are a nightmare that takes a lot of time to set up and fix.
Writing synchronous code is faster and easier, and I personally agree to put up with conditionally suboptimal execution speed, if this means that I can implement the basic business logic faster.
It may sound a little disregard for the performance characteristics of this model, but keep in mind that it is only slow compared to a purely asynchronous stack (i.e., futures). This is still a conceptually correct model with real parallelism and compared to any other frameworks and programming languages, it is very, very fast. I work on Ruby at my main job and compared to a non-streaming model (common to Ruby, because GIL limits thread performance), this model is an order of magnitude better and more efficient in terms of memory usage.
In the end, your database will be a bottleneck, and the synchronous model of actors supports just such parallelism, while at the same time ensuring maximum throughput for any actions that do not need access to the database.
Like any good Rust program, APIs almost everywhere return the Result
type. Futures
use their version of Result
containing either a successful result or an error.
I use error_chain to determine my errors. Most of them are internal, but I have defined a specific group with a direct goal:
error_chain!{ errors { // // User errors // BadRequest(message: String) { description("Bad request"), display("Bad request: {}", message), } } }
When an error has to be passed to the user, I will necessarily associate it with one of my types of errors:
Params::build(log, &request).map_err(|e| ErrorKind::BadRequest(e.to_string()).into() )
After waiting for a response from a synchronous actor or after trying to create a successful HTTP response, I process errors and send a response to the user. The implementation turned out to be quite elegant (note that in the composition Future::then
differs from and_then in that it processes both success and failure, receiving a Result
, unlike and_then
which processes only a successful completion):
let message = server::Message::new(&log, params); // Send message to synchronous actor sync_addr .send(message) .and_then(move |actor_response| { // Transform actor response to HTTP response } .then(|res: Result<HttpResponse>| server::transform_user_error(res, render_user_error) ) .responder()
Errors that are not intended for the user are logged, and actix-web
returns them as 500 Internal server error
(although I probably will add my own visualizer to it at some point).
Here is transform_user_error
. The render
function abstracts error handling, so we can reuse this function in different APIs that display JSON responses, and a web server that displays HTML.
pub fn transform_user_error<F>(res: Result<HttpResponse>, render: F) -> Result<HttpResponse> where F: FnOnce(StatusCode, String) -> Result<HttpResponse>, { match res { Err(e @ Error(ErrorKind::BadRequest(_), _)) => { // `format!` activates the `Display` traits and shows our error's `display` // definition render(StatusCode::BAD_REQUEST, format!("{}", e)) } r => r, } }
As web frameworks in many languages, actix-web
supports middleware
. Here is a simple example that initializes the logger for each query and sets it to the query extension (a set of query states that will work as long as the query is executed):
pub mod log_initializer { pub struct Middleware; pub struct Extension(pub Logger); impl<S: server::State> actix_web::middleware::Middleware<S> for Middleware { fn start(&self, req: &mut HttpRequest<S>) -> actix_web::Result<Started> { let log = req.state().log().clone(); req.extensions().insert(Extension(log)); Ok(Started::Done) } fn response( &self, _req: &mut HttpRequest<S>, resp: HttpResponse, ) -> actix_web::Result<Response> { Ok(Response::Done(resp)) } } /// Shorthand for getting a usable `Logger` out of a request. pub fn log<S: server::State>(req: &mut HttpRequest<S>) -> Logger { req.extensions().get::<Extension>().unwrap().0.clone() } }
The peculiarity is that middleware
binds to a type instead of a string (as, for example, Rack in Ruby). This not only helps to check the type during compilation in such a way that you can not mistakenly keep the key, but also gives the middleware
ability to control its modularity. If we wanted to hide the middleware
, we could remove the pub
from the Extension so that it becomes closed. Any other modules would not be able to access this data due to the compiler checking the visibility.
Like request handlers, middleware
can be asynchronous, returning the future instead of Result. This will allow, for example, to implement middleware
, limiting the transfer rate, which Redis would use in such a way as not to block other handlers. I have already mentioned that actix-web
pretty fast?
The actix-web
documentation describes several guidelines for testing methodologies for your code. I settled on a series of unit tests that use TestServerBuilder
to create a small application containing a single handler, and then execute a query against it. This is a good compromise, because, despite the minimal tests, they use the full HTTP stack, and because of what they become fast and complete:
#[test] fn test_handler_graphql_get() { let bootstrap = TestBootstrap::new(); let mut server = bootstrap.server_builder.start(|app| { app.middleware(middleware::log_initializer::Middleware) .handler(handler_graphql_get) }); let req = server .client( Method::GET, format!("/?query={}", test_helpers::url_encode(b"{podcast{id}}")).as_str(), ) .finish() .unwrap(); let resp = server.execute(req.send()).unwrap(); assert_eq!(StatusCode::OK, resp.status()); let value = test_helpers::read_body_json(resp); // The `json!` macro is really cool: assert_eq!(json!({"data": {"podcast": []}}), value); }
I actively use serde_json
(standard Rust encoding and decoding library) json!
macro, used in the last line of the code above. If you look carefully, you will notice that the embedded JSON is not a string - json!
Which allows me to write the actual JSON notation directly into my code, which will be tested and converted to a real Rust structure by the compiler. This is the most elegant approach to testing HTTP JSON responses I've ever seen in other programming languages.
It would be fair to say that I could write the same service in Ruby 10 times faster than Rust. Part of this time was spent on training, part on taming the obstinate compiler, which sometimes turns into a long and frustrating process. However, once again encountering this last obstacle, I launched my program, experiencing euphoria from the fact that it works exactly as I want. Compare this with the interpreted languages, when you may be able to start the program with 15 attempts, but even then the boundary conditions will be almost one hundred percent incorrect. Rust also allows you to make big changes - for me it is often necessary to reorganize a thousand lines at a time, and then again and even after that the program works fine. Anyone who has seen a large program in an interpreted language in production knows that you can only make changes in small parts, otherwise you are at great risk. Should you write your next web service on Rust? I do not know yet, but you definitely should pay attention to him.
Source: https://habr.com/ru/post/353990/
All Articles