Over the summer, I prototyped a bunch of web apps whose ideas had been floating in my mind for a long time. I spent quite a bit of time learning about REST APIs and, as part of these exercises, implemented skeletons of REST servers in both Go and Rust. (Just for context, the last time I wrote a web app was in high school… and it involved PHP, MySQL, and I think IE6? Times have changed.)
Why Go? Because the first app I wanted to build was to run on App Engine and Go was an easy choice after trying and giving up on Java. Why Rust? Because I enjoy it more and wanted to see how it would stack up to Go in this task.
The app prototypes have gone nowhere but I thought of sharing the skeletons I built if only to serve as templates for myself in future work. Thus, in this post, I will cover these skeleton demos and I will compare them.
Guiding principles
The basic constraints that led to these demos are:
Decouple the REST API manipulation from the business logic. The web API is the interface with users and should not be tied to the internal business logic nor backend. I’m new to REST, but clearly defining the boundary between the outside world and the server’s internals is just good design practice in any paradigm, be it command line tools or protobuf-based web servers—of which I do have written a bunch as they are the daily bread at Google.
Using JSON for the requests and responses as the serialization format. You’ll note that in some cases I’m introducing data types that are not strictly needed for these toy demos, but the goal is to see how easy (or not) it is to define and integrate such types.
Storing state in-memory only. We don’t need persistence for this demo code and keeping everything in memory is sufficient.
Staying away from high-level do-it-all frameworks. It may be convenient to use those, but I wanted to see how things work under the covers. I’ll be using HTTP routing APIs but that’s about it.
Error handling needn’t be very accurate. It’s a demo after all.
Task management API
The demos implement a To-Do manager, of course. (That’s not what I built as a prototype but was a nicer example for this post.) The API we will implement will let us manipulate a collection of tasks where each task contains a textual description and a bit to indicate whether it is done or not.
We will expose these methods:
GET /task
: Gets a list of all tasks.- Input: empty.
- Output: a map of task resource locators to a map of task definitions. The task definition is the same as returned by the
GET /task/{id}
described below.
POST /task
: Creates a new note and returns the path to the created note. Given that task IDs are assigned internally, there is noPUT /task/{id}
to create a new task with a specific ID.- Input: empty.
- Output: a string with the task resource locator on success, or an error message on failure.
GET /task/{id}
: Gets the given task.- Input: empty.
- Output: a map with the following keys on success:
text
containing the description of the task as a string, anddone
representing whether the task is completed as a boolean; or an error message on failure.
UPDATE /task/{id}
: Modifies fields of the given task.- Input: a map with the following optional keys to represent the fields that need updating:
text
with the new description of the task as a string, anddone
with the new representation of the task’s completion as a boolean. - Output: empty on success, or an error message on failure.
- Input: a map with the following optional keys to represent the fields that need updating:
DELETE /task/{id}
: Deletes the given note.- Input: empty.
- Output: empty on success, or an error message on failure.
Rust version
In the Rust version, we’ll use the Rouille web micro-framework. This is not the most advanced framework nor the fastest, but its simplicity lets us see the inner workings of the system. We’ll also use the popular Serde framework for JSON (de)serialization. Put in a package manifest:
[package]
name = "rest-api-rust"
version = "0.1.0"
edition = "2018"
[dependencies]
rouille = "3.0.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
First off, let’s start by looking into the business logic and data store:
use std::collections::HashMap;
use std::result::Result;
/// Definition of a single task.
pub struct Task {
/// The description of the task.
pub text: String,
/// Whether the task is complete or not.
pub done: bool,
}
/// Container of tasks.
///
/// Tasks are identified by an integer and each points to a separate `Task`
/// object that describes that individual task.
///
/// The task manager is not thread-safe.
pub struct TaskManager {
tasks: HashMap<usize, Task>,
next_id: usize,
}
impl TaskManager {
/// Creates a new task manager with no tasks.
pub fn new() -> Self {
Self { tasks: HashMap::new(), next_id: 0 }
}
/// Returns all tasks.
pub fn all(&self) -> &HashMap<usize, Task> {
&self.tasks
}
/// Adds a new undone task based on its textual description and returns
/// the identifier assigned to it.
pub fn add(&mut self, text: String) -> usize {
let id = self.next_id;
let task = Task { text: text, done: false };
if self.tasks.insert(id, task).is_some() {
panic!("Overwrote task; did next_id wrap?");
}
self.next_id += 1;
id
}
/// Returns the task corresponding to the identifier `id` or an error
/// message if not found.
pub fn get(&mut self, id: usize) -> Result<&Task, &'static str> {
self.tasks.get(&id).ok_or("No such task")
}
/// Updates the task corresponding to the identifier `id` with a new
/// text and status. If any parameter is set to `None`, the corresponding
/// field in the task is left unmodified. Returns an error message if the
/// task is not found.
pub fn set(&mut self, id: usize, text: Option<String>, done: Option<bool>)
-> Result<(), &'static str> {
self.tasks.get_mut(&id).map_or(
Err("No such task"),
|v| {
if let Some(text) = text {
v.text = text;
}
if let Some(done) = done {
v.done = done;
}
Ok(())
})
}
/// Deletes the task corresponding to the identifier `id` or an error
/// message if not found.
pub fn delete(&mut self, id: usize) -> Result<(), &'static str> {
self.tasks.remove(&id).map_or(Err("No such task"), |_| Ok(()))
}
}
The TaskManager
is the in-memory container for all the tasks we want to manage. Note that nothing in this type knows about our public-facing API: in particular, there is absolutely no (de)serialization code in here. All methods in this structure deal with primitive data types. This is intentional to ensure changes to the backend do not intentionally cause subtle differences in the publicly exposed API, and to enforce this restriction, the code lives in a separate file to tightly control the dependencies it pulls in.
Notably, the Task
type is not serializable. It is extremely tempting to make it so because it would be very convenient later on, but past experience says that this is a bad idea. It’s too easy for internal data types to evolve fields that are only necessary inside the server and should never be visible outside, but coupling them with the public data type makes this hard to enforce. And before you realize it, your public API is poisoned with fields that were never meant to exist and cleaning them up is a gargantuan effort. So. Refrain from this temptation unless you must give in due to justified performance reasons.
With the backend defined, let’s look at the web server:
extern crate rest_api_rust;
extern crate serde;
#[macro_use] extern crate rouille;
use rest_api_rust::{Task, TaskManager};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Mutex;
/// External representation of a single task.
#[derive(Serialize)]
struct GetTaskResponse<'a> {
text: &'a str,
done: bool,
}
impl<'a> GetTaskResponse<'a> {
/// Constructs a new external version of a task given a task's definition.
fn from(t: &'a Task) -> Self {
Self { text: &t.text, done: t.done }
}
}
/// Representation of a request to update zero or more fields of a task.
#[derive(Deserialize)]
struct UpdateTaskRequest {
text: Option<String>,
done: Option<bool>,
}
/// Processes REST requests for the task manager API and transforms them into
/// operations against the given backing `task_manager`.
fn route_request(request: &rouille::Request, task_manager: &Mutex<TaskManager>)
-> rouille::Response {
let mut task_manager = task_manager.lock().unwrap();
router!(request,
(GET) ["/task"] => {
let mut response = HashMap::new();
for (id, task) in task_manager.all().iter() {
let path = format!("/task/{}", id);
response.insert(path, GetTaskResponse::from(task));
}
rouille::Response::json(&response)
},
(POST) ["/task"] => {
let body: String =
try_or_400!(rouille::input::json_input(request));
let id = task_manager.add(body);
rouille::Response::json(&format!("/task/{}", id))
},
(GET) ["/task/{id}", id: usize] => {
match task_manager.get(id) {
Ok(task) =>
rouille::Response::json(&GetTaskResponse::from(task)),
Err(e) => rouille::Response::json(&e).with_status_code(404),
}
},
(UPDATE) ["/task/{id}", id: usize] => {
let body: UpdateTaskRequest =
try_or_400!(rouille::input::json_input(request));
match task_manager.set(id, body.text, body.done) {
Ok(()) => rouille::Response::empty_204(),
Err(e) => rouille::Response::json(&e).with_status_code(404),
}
},
(DELETE) ["/task/{id}", id: usize] => {
match task_manager.delete(id) {
Ok(()) => rouille::Response::empty_204(),
Err(e) => rouille::Response::json(&e).with_status_code(404),
}
},
_ => rouille::Response::empty_404()
)
}
fn main() {
let task_manager = Mutex::from(TaskManager::new());
rouille::start_server(
"localhost:1234", move |request| route_request(request, &task_manager))
}
The web server layer is where we define exactly what URLs we expose to the world, what messages they accept, what messages they return, and what error conditions they react to. Here is where we map internal data structures to public JSON data structures. There is no business logic here.
Go version
In the Go version, we’ll use the gorilla/mux HTTP router and URL matcher for simplicity. We could roll our own URL method handling pretty easily, but the code is already verbose enough as it is, and doing this kind of parsing by hand is fragile and boring. So the modules definition:
module rest-api-go
go 1.12
require github.com/gorilla/mux v1.7.3
Similarly to the Rust version, the business logic lives in a separate module to ensure it remains completely agnostic to the external world. And in that same spirit, note that none of the code here contains JSON annotations:
package tasks
import (
"fmt"
)
// Task represents a single task.
type Task struct {
// Text contains the description of the task.
Text string
// Done contains whether the task is complete or not.
Done bool
}
// TaskManager implements a container of tasks.
//
// Tasks are identified by an integer and each points to a separate Tas
// object that describes the individual task.
type TaskManager struct {
nextID uint32
tasks map[uint32]Task
}
// NewTaskManager creates a new task manager with no tasks.
func NewTaskManager() TaskManager {
return TaskManager{nextID: 0, tasks: make(map[uint32]Task)}
}
// All returns all tasks.
func (tm *TaskManager) All() *map[uint32]Task {
return &tm.tasks
}
// Add adds a new undone task based on its textual representation and returns
// the identifier assigned to it.
func (tm *TaskManager) Add(text string) uint32 {
id := tm.nextID
tm.tasks[id] = Task{Text: text, Done: false}
tm.nextID++
return id
}
// Get returns the task corresponding to the given identifier or an error
// if not found.
func (tm *TaskManager) Get(id uint32) (Task, error) {
task, ok := tm.tasks[id]
if !ok {
return Task{}, fmt.Errorf("no such task")
}
return task, nil
}
// Set updates the task corresponding to the given identifier with a new
// text and status. If any parameter is nil, the corresponding field in the
// task is left unmodified. Returns an error if the task is not found.
func (tm *TaskManager) Set(id uint32, text *string, done *bool) error {
task, ok := tm.tasks[id]
if !ok {
return fmt.Errorf("no such task")
}
if text != nil {
task.Text = *text
}
if done != nil {
task.Done = *done
}
tm.tasks[id] = task
return nil
}
// Delete deletes the task corresponding to the given identifier or an error
// if not found.
func (tm *TaskManager) Delete(id uint32) error {
_, ok := tm.tasks[id]
if !ok {
return fmt.Errorf("no such task")
}
delete(tm.tasks, id)
return nil
}
With the backend defined, and with the same logic behind the Rust version, here comes the web server:
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"sync"
"rest-api-go/tasks"
"github.com/gorilla/mux"
)
// getTaskResponse represents a task as a serializable JSON object.
type getTaskResponse struct {
Text string `json:"text"`
Done bool `json:"done"`
}
// newGetTaskResponse constructs a new external version of a task given a
// task's definition.
func newGetTaskResponse(t tasks.Task) getTaskResponse {
return getTaskResponse{Text: t.Text, Done: t.Done}
}
// updateTaskRequest represents a request to update zero or more fields of
// a task.
type updateTaskRequest struct {
Text *string `json:"text,omitempty"`
Done *bool `json:"done,omitempty"`
}
// getUint32 gets a variable from a request as an uint32 and returns an
// error if the string value cannot be converted.
func getUint32(r *http.Request, field string) (uint32, error) {
vars := mux.Vars(r)
value, ok := vars[field]
if !ok {
panic("Invalid variable identifier")
}
value64, err := strconv.ParseUint(value, 10, 32)
if err != nil {
return 0, fmt.Errorf("invalid task identifier %s: %v", value, err)
}
return uint32(value64), nil
}
// handler is a convenience wrapper to implement the body of an HTTP handler.
// Takes care of ensuring the backing task manager is locked via the mu mutex,
// constructs a JSON decoder and encoder to handle the request and the response,
// and if the handler returns an error, responds to the HTTP request with the
// details.
func handler(mu *sync.Mutex, w http.ResponseWriter, r *http.Request,
f func(encoder *json.Encoder, decoder *json.Decoder) (int, error)) {
mu.Lock()
defer mu.Unlock()
encoder := json.NewEncoder(w)
decoder := json.NewDecoder(r.Body)
code, err := f(encoder, decoder)
if err != nil {
w.WriteHeader(code)
encoder.Encode(err.Error())
}
}
func main() {
mu := sync.Mutex{}
tm := tasks.NewTaskManager()
router := mux.NewRouter()
router.HandleFunc("/task", func(w http.ResponseWriter, r *http.Request) {
handler(&mu, w, r, func(encoder *json.Encoder, decoder *json.Decoder) (int, error) {
response := make(map[string]getTaskResponse)
for id, task := range *tm.All() {
path := fmt.Sprintf("/task/%d", id)
response[path] = newGetTaskResponse(task)
}
if err := encoder.Encode(response); err != nil {
return http.StatusInternalServerError, err
}
return 0, nil
})
}).Methods("GET")
router.HandleFunc("/task", func(w http.ResponseWriter, r *http.Request) {
handler(&mu, w, r, func(encoder *json.Encoder, decoder *json.Decoder) (int, error) {
text := ""
if err := decoder.Decode(&text); err != nil {
return http.StatusInternalServerError, err
}
id := tm.Add(text)
if err := encoder.Encode(fmt.Sprintf("/task/%d", id)); err != nil {
return http.StatusInternalServerError, err
}
return 0, nil
})
}).Methods("POST")
router.HandleFunc("/task/{id}", func(w http.ResponseWriter, r *http.Request) {
handler(&mu, w, r, func(encoder *json.Encoder, decoder *json.Decoder) (int, error) {
id, err := getUint32(r, "id")
if err != nil {
return http.StatusBadRequest, err
}
task, err := tm.Get(id)
if err != nil {
return http.StatusNotFound, err
}
if err := encoder.Encode(newGetTaskResponse(task)); err != nil {
return http.StatusInternalServerError, err
}
return 0, nil
})
}).Methods("GET")
router.HandleFunc("/task/{id}", func(w http.ResponseWriter, r *http.Request) {
handler(&mu, w, r, func(encoder *json.Encoder, decoder *json.Decoder) (int, error) {
id, err := getUint32(r, "id")
if err != nil {
return http.StatusBadRequest, err
}
update := updateTaskRequest{}
if err := decoder.Decode(&update); err != nil {
return http.StatusInternalServerError, err
}
if err := tm.Set(id, update.Text, update.Done); err != nil {
return http.StatusNotFound, err
}
w.WriteHeader(http.StatusNoContent)
return 0, nil
})
}).Methods("UPDATE")
router.HandleFunc("/task/{id}", func(w http.ResponseWriter, r *http.Request) {
handler(&mu, w, r, func(encoder *json.Encoder, decoder *json.Decoder) (int, error) {
id, err := getUint32(r, "id")
if err != nil {
return http.StatusBadRequest, err
}
if err := tm.Delete(id); err != nil {
return http.StatusNotFound, err
}
w.WriteHeader(http.StatusNoContent)
return 0, nil
})
}).Methods("DELETE")
log.Fatal(http.ListenAndServe("localhost:1234", router))
}
Testing the APIs
All done. You can copy/paste the files above, build them, and test our shiny-new server using the curl
command line tool. Because JSON is all text, it’s easy to hand-craft the requests and to read the responses. You can also use some of these fancier clients that exist for Firefox or Chrome, but no need to.
Let’s start by creating a bunch of tasks:
$ curl -X POST -H "Content-Type: application/json" -d '"Publish REST post"' http://localhost:1234/task
"/task/0"
$ curl -X POST -H "Content-Type: application/json" -d '"Pay rent"' http://localhost:1234/task
"/task/1"
$ curl -X POST -H "Content-Type: application/json" -d '"Implement web interface"' http://localhost:1234/task
"/task/2"
The server now knows about them, of course:
$ curl -X GET http://localhost:1234/task
{"/task/0":{"text":"Publish REST post","done":false},"/task/1":{"text":"Pay rent","done":false},"/task/2":{"text":"Implement web interface","done":false}}
And as I’m done with this post, let’s mark its task as done:
$ curl -X UPDATE -H "Content-Type: application/json" -d '{"done": true}' http://localhost:1234/task/0
Which, as expected, will update the state of that note:
$ curl -X GET http://localhost:1234/task
{"/task/0":{"text":"Publish REST post","done":true},"/task/1":{"text":"Pay rent","done":false},"/task/2":{"text":"Implement web interface","done":false}}
We can also query a single note:
$ curl -X GET http://localhost:1234/task/1
{"text":"Pay rent","done":false}
And say we don’t want to do it any longer:
$ curl -X DELETE http://localhost:1234/task/1
At which point it’s gone:
$ curl -X GET http://localhost:1234/task
{"/task/0":{"text":"Publish REST post","done":true},"/task/2":{"text":"Implement web interface","done":false}}
Comparison
Now, let’s get to what you were really waiting for. How do Rust and Go compare to each other in this situation? As I have said in the past, this comparison is often unfounded because languages should be chosen by use case, but in this situation I think we can reasonably do a light comparison.
Go shines in implementing network servers. If you think of the context in which it was invented, this is very much intentional. Rust, on the other hand, is a lower-level systems language, so I was somehow expecting more pain to implement this example. But nothing further from the truth: you can see that the two examples are very similar in content and line count.
The Go variant is slightly more verbose than the Rust one, which is expected given the intent of Go code to be easy to read. I find this very limiting though as I have mentioned in the past: it’s impossible to encode certain constraints in the Go code without writing comments, whereas in Rust you have the tools to do so. This little detail brings more reliability and security to your code, which is something pretty important for a publicly-facing web server I’d say…
Regarding performance, I’m not going to touch much on it given that these are demo apps where I put no thought into performance. The comparison would be unfair as well because of the use of Rouille, which it itself acknowledges its own limitations in this area. That said, one little detail is that the Go code has to do some more memory copies than the Rust code. In Rust I was able to expose the internal Task
s to the caller as references without fear that they’d be modified, but in Go this is impossible to represent: exposing pointers allows the caller to modify the contents.
Anyway, not a very satisfying comparison, right? I agree. That’s because both choices seem equally good to me, and I’d be picking one or the other based on the specific use case at hand.
Enjoy and let me know if you found this useful!