Possibilities of Rust for Web Applications
Rust has been gaining popularity among developers, being the most-loved language in Stack Overflow’s annual survey for the past five years. However, other indexes, such as TIOBE, rank it below the 20 most-used languages. So why is Rust one of the most loved languages by developers but still being ranked so low? Some say it is difficult to find developers currently working with Rust or companies using it for active development. So what does all this mean? While Rust is recognized as a valuable tool by the developer community, its application has a high learning curve due to its specialization and particularities. So why invest in such a language? How does the future of Rust look?
To answer these questions, we will take a look at the language, its syntax, its basic features, and how Rust code is written for web applications.
What are the characteristics of Rust?
Rust originated within the Mozilla team in 2010 as a side project. Since then, it has evolved into a community-driven language, with foundation.rust-lang.org as its main governance institution.
It has some characteristics that, altogether, make it different from many other languages:
- It is a compiled language
- It is strongly typed
- Lacks a garbage collection system, using a «borrow checker» instead
- Provides pointers/references for stack and advanced pointers for heap memory management
- Allows both object-oriented and functional programming
- It is conceived specially for concurrent code development
- It is agnostic to the asynchronous runtime
- Has a test suite integrated into the language
Let’s go through some of these points to find out what they offer:
Being a compiled language, Rust will provide a lot of information about the program’s memory usage before runtime. Furthermore, using strongly-typed structures, Rust guarantees that its memory is safe once the program compiles. Of course, we as developers can make mistakes, especially regarding business logic, but the compiler ensures that our programs will be as structurally performant and coherent as possible.
This process is done automatically for values with known sizes stored in the stack. For these values, we can use C-like pointers (*&) to reference and dereference them within some constraints imposed by Rust to avoid unsafe memory handling.
For those values whose sizes are not known beforehand by the compiler and are stored in a heap, such an array —«Vector»—, Rust provides specific structures to wrap them (Box, Rc, Arc, etc.), ensuring that they are cleaned as soon as possible.
One of the main features of Rust is a simplified version of C structs instead of C++/Java-like classes. There are three main keywords related to structs in Rust: struct, impl, and trait. We can define a new struct using the struct keyword with the values it holds. After defining it, we can add methods with the impl keyword. Finally, in case we want to describe a common behavior for several implementations, we can do it with the trait keyword, similar to what other languages call an interface:
Using structs instead of classes will prevent nested structures, favoring composition instead. Rust will allow us to write code in an object-oriented and functional style within a limited frame. It is perfectly possible to use object-oriented patterns like dependency injection, or features such as polymorphism, to write functional stateless piped code with closures. Both approaches fit naturally with some constraints that force the developer to write atomic and readable structures. The resulting code has a somewhat complex syntax, but the flat and simple structures make the code easier to read and understand.
Rust comes with a standard library that provides several functionalities. Among them are threading tools, which will allow us to write concurrent programs. With the help of the compiler, static typing, borrow checker, and advanced pointers, these tools will ensure that our multi-threaded program is memory safe, even in multi-threaded programs.
An interesting feature of Rust is that it is not coupled with an asynchronous runtime. Instead, we will need to bring an external library: a very common option is the Tokio library, which can be found in the Rust official packages repository, although there are other options available.
The tests are written with the embedded test suite. It is possible to write unit tests for each module and integration tests for the whole application. Its features are limited but clear, following the philosophy of the language.
Due to the considerable paradigm shift, working with Rust after coming from Java-like languages is somewhat confusing. But, of course, the language writes naturally once this difficulty is overcome. It has similarities to commonly used languages such as C++ and C, with a cleaner footprint. Given its tools for memory management and generics, it could easily be described as «declarative C».
A Basic Rust Example
To demonstrate how Rust’s program can be structured, we can create a naive and straightforward application to visualize the main language features.
Following our previous example, we can develop a program to ask for a user’s list from a PostgreSQL database. However, we want the list of users in reverse order this time.
Starting out, we have some different concerns here:
- The User itself, which will be a plain model
- The business logic, which reverses the retrieved list’s order, will be a use case. This use case will use any retrieval system that complies with a trait.
- Finally, we will need the system to retrieve data from the PostgreSQL database using a struct. To avoid coupling the use case with the PostgreSQL repository, we can create a trait that all repositories must comply with. The use case will receive any repository instance that complies with this trait.
Now that we have an idea of our application, it’s time to implement it. We will omit a few lines of code needed for the program to compile, but the whole example can be seen in the Rust Playground: play.rust-lang.org.
First, we can create our entity, a User with just an id field:
Now that we have our primary model, the next question is what we want to do with it. In other words, we need the use case to retrieve a reverted list of users. We know it will need to hold the repository instance, but we don’t know the type – this is a task for a generic type.
Our use case gathers the type of the repository instance when it is constructed. Now, let’s create the new method to build it, for which we can use the impl keyword:
One thing to note here is the <T: Repository> syntax. We tell the compiler that T will be any struct implementing the Repository trait, returning an error if any other instance is passed. Also, our new method returns an instance of itself.
We can think now about the different repositories that this use case may depend on. We know that it will be an asynchronous action and return a list of users. Let’s implement this in a trait:
We have new items here. First, we have the async keyword, making our function return a Result type. This Return type is one of the particularities of Rust: it represents the result of an action that can be successful —Ok(T)— or error —Err(K)—. In our case, if the method is successful, it will return Ok<Vec<User>>, which is an array —a vector in Rust— of User items. If it errors, it will return Err(String). For the sake of brevity, we won’t produce any errors, but the Result type forces us to take that possibility into account as we are writing asynchronous code.
Now we can think about how we will trigger our use case, so let’s implement an execute method.
We see the async syntax awaiting the repo function we are calling. We also see the Result type again, making everything familiar.
As the repository returns a Result —Ok<T> or Err<K>— type, we need to extract the value —the array of users— to operate with it. We do this with the unwrap() function, extracting the value if the operation is successful or returning the error if it’s not.
We also have methods operating over the users vector to reverse it:
- Into_iter turns a vector into an iterable list of items.
- Rev reverses the iterable list.
- Collect returns the iterable list as a vector.
These utility methods belong to the Vec struct and are embedded into Rust to manipulate vectors in a functional manner.
We now have a use case and interface for any repository we may pass into it, so now we can create an actual repository for PostgreSQL:
We will mock asynchronous behavior, so we are using two methods of the standard library: sleep and Duration. Duration will return a special time type that other methods, such as sleep, can use, and sleep will halt execution for the given time. So we halted our function for two seconds, instantiated two users, and finally returned a Vector holding them.
We now have all the needed components for our program: a User entity, the GetUsersRevertedUseCase, the interface Repository, and the PostgreSQLRepository. Now, let’s create an entry point and make use of all of them:
As we are running asynchronous code, we will need an asynchronous runtime. Thus, we can call the macro #[tokio:main]. Then we create an instance of the repository, construct our use case that injects the repository, and execute it. The result will be a vector of users in reverse order:
As mentioned above, this code is available in the Rust Playground: play.rust-lang.org, where we can compile and run it.
Rust is not a straightforward language. Study and patience are required to understand the basic syntax and its underlying features. It is also young, and its ecosystem has to evolve. But it is promising.
Performance-demanding tasks are where the language fits perfectly. However, for general web development, where rapid prototyping with agile workflows is required, interpreted languages such as Python or TypeScript may be more suitable. Still, in cases where memory leaks may cause spikes and performance issues in server usage, Rust is a much better option.
The current acceptance of Rust within the industry is minimal. Still, the Rust foundation is supported by Google, Microsoft, AWS, Huawei, and Mozilla. There is a need for such a performant language, and we will most likely see how it is introduced in the following years, but some difficulties remain. The funding companies will try to influence Rust to fit their particular needs, which causes strong disagreements among the internal Rust development teams. But the language works very well for a wide range of tasks, such as the one shown above, and it will be worthwhile to follow its evolution and integration into the web industry.
In conclusion: it is possible and desirable to use Rust in specific contexts where performance and assurance are required, which in web development is the case of microservices, WebAssembly, or critical parts of an application.