Although WebAssembly is an excellent fit for bringing complex solutions that need near-native runtime performance to the client (Browser), I see WebAssembly as a technology with way more potential and a way bigger impact on the server (read: cloud) in contrast. That said, WebAssembly will not replace containers on the server side. It’s an addition or alternative approach to packaging and distributing specific software components typically part of a bigger system. It’s on us - the developers and software architects - to decide if a container or WebAssembly is the better foundation for meeting a particular requirement.
We’re still in the early ages of WebAssembly (especially on the server side), where specs and ideas like WebAssembly System Interface (WASI) and WebAssembly Gateway Interface (WAGI) lay out the foundation for infrastructure frameworks application developers will build on. Besides WASI and WAGI, there is also the WebAssembly Component Model spec (the community is currently working towards the first release of the spec).
So still a lot of moving parts here. However, companies like Fermyon are pioneering in this area and building awesome ideas and technology to boost WebAssembly adoption on the server side. In this post, we will look at Spin, an open-source framework, runtime, and developer tooling created by Fermyon.
- What is Fermyon Spin
- Getting started with Fermyon Spin
- HTTP-Trigger routing capabilities
- Dealing with HTTP methods in Spin
- Conclusion
What is Fermyon Spin
Fermyon (founded in November 2021) does excellent pioneering work in the WebAssembly space. With Spin, they provide everything we need to build microservices. Spin is a holistic suite that consists of
- Software Development Kit (SDK)
- Runtime
- Developer Toolchain
The Spin SDK is available for a wide range of programming languages that we can use to build our microservices. The SDK comes with batteries included that address everyday use cases like interacting with Redis and PostgreSQL or sending outbound HTTP requests. With Spin, we build microservices using the Web Assembly Component Model.
Today we are releasing a new language SDK for @fermyontech Spin that lets you write #WebAssembly microservices in #JavaScript and #TypeScript.
— Radu Matei (@matei_radu) December 8, 2022
This is based on the amazing work from @saulecabrera of @ShopifyEng and others on the Javy project.https://t.co/uIZKseMZMU pic.twitter.com/jJv4TcA73A
Although the team at Fermyon keeps on adding more and more languages, you can use any language that can be compiled to WebAssembly leveraging the WAGI executor provided by Spin.
Spin also acts as runtime, meaning that Spin loads and runs our custom microservices and instantiates them when the desired trigger is invoked. Speaking of triggers, Spin has two out-of-the-box triggers: HTTP and Redis. We can add more specialized triggers by individually extending Spin, as described in this article.
People at Fermyon are super focused on inner-loop performance. The’ spin’ CLI is everything we need to create and run applications. We use spin new
to create a new microservice. We compile it with spin build
(actually, spin build
invokes the compiler of the chosen programming language as configured in the application manifest); Finally, we use spin up
to run the microservice locally.
Getting started with Fermyon Spin
First, let’s install spin
on our local development machine. We can download the latest release from the GitHub repository. If you have a local installation of Rust, you can clone the Spin repository and use cargo
to install spin
:
# clone the spin repository
git clone https://github.com/fermyon/spin -b v0.3.0
cd spin
# ensure you've the wasm32-wasi platform installed for rust
rustup target add wasm32-wasi
# Build & install spin
cargo install --path .
Let’s quickly verify the installation and invoke spin --version
, which should (at the point of writing this article return spin 0.6.0
(with an additional git commit hash and the corresponding date).
Install the Spin starter templates
We use the spin
CLI to create new projects. Upon creating a new project, we can choose from different templates to get started as fast as possible. To get access to those starter templates, we must install them by invoking spin templates install --git https://github.com/fermyon/spin
. We can list all installed templates as shown here:
# see all installed templates
spin templates list
+-----------------------------------------------------------------+
| Name Description |
+=================================================================+
| http-c HTTP request handler using C and the Zig toolchain |
| http-go HTTP request handler using (Tiny)Go |
| http-grain HTTP request handler using Grain |
| http-rust HTTP request handler using Rust |
| http-swift HTTP request handler using SwiftWasm |
| http-zig HTTP request handler using Zig |
| redis-go Redis message handler using (Tiny)Go |
| redis-rust Redis message handler using Rust |
+-----------------------------------------------------------------+
Hello World - The Spin Edition
Now that we have installed the spin
CLI and the starter templates, we will build “Hello World” to give Spin a spin (SCNR 😁). The spin
CLI will ask for necessary details about our hello world sample when we invoke spin new
:
# move to your source directory (here ~/sources)
cd ~/sources
# create a new microservice in the ./hello-world folder
# use http-rust as template
# provide hello-world as the name
spin new -o ./hello-world http-rust hello-world
Project description: Hello World - The Spin Edition
HTTP base: /
HTTP path: /...
We’ll dive into things like HTTP base
and routing capabilities for HTTP triggers later. For the sake of this demo, stick with the defaults. We end up with a bootstrapped Rust project and a manifest called spin.toml
. The manifest is used for wiring up the Wasm component at runtime. However, let’s first revisit lib.rs
:
use anyhow::Result;
use spin_sdk::{
http::{Request, Response},
http_component,
};
/// A simple Spin HTTP component.
#[http_component]
fn hello_world(req: Request) -> Result<Response> {
println!("{:?}", req.headers());
Ok(http::Response::builder()
.status(200)
.header("foo", "bar")
.body(Some("Hello, Fermyon".into()))?)
}
As the generated sample outlines, we can use the spin SDK to construct an HTTP response based on information we find in the inbound HTTP request. This is more convenient than working with environment variables and STDOUT
as we would have to do when using plain WAGI
.
I bet you’re able to understand what the code does (no matter if you’ve ever used Rust before or not). For every inbound request, it will create an HTTP response with status code 200
, add the HTTP header foo
providing bar
as the value and send Hello, Fermyon
as the response body. The preceding code will also log all incoming HTTP request headers to STDOUT
for demonstration purposes.
Besides the code, it’s also important to make yourself comfortable with the manifest. It contains essential metadata about the microservice and the invoked components based on the trigger (HTTP here). In real-world applications, you’ll lay out all your components that build the bigger system and provide both: essential metadata and configuration per component. You can find the following spin.toml
in the project directory:
spin_version = "1"
authors = ["Thorsten Hans <[email protected]>"]
description = "Hello World - The Spin Edition"
name = "hello-world"
trigger = { type = "http", base = "/" }
version = "0.1.0"
[[component]]
id = "hello-world"
source = "target/wasm32-wasi/release/hello_world.wasm"
[component.trigger]
route = "/..."
[component.build]
command = "cargo build --target wasm32-wasi --release"
Running Hello World - The Spin Edition
We can quickly run the “Hello World” sample either using the combination of spin build
and spin up
, or we can take a shortcut using spin build --up
:
# run hello world
cd ~/sources/hello-world
# build hello world
spin build
# run hello world
spin up
Serving http://127.0.0.1:3000
Available Routes:
hello-world: http://127.0.0.1:3000 (wildcard)
Now, we can test “Hello World” by sending an HTTP request to http://127.0.0.1:3000
using the HTTP Client of your choice (curl
here):
# call hello world
curl -iX GET http://127.0.0.1:3000
HTTP/1.1 200 OK
foo: bar
content-length: 14
date: Tue, 12 Jul 2022 07:29:11 GMT
Hello, Fermyon%
Are you already curious about performance? What about sending 10k requests with a concurrency of 50? We can do that with ease using tools like hey
(https://github.com/rakyll/hey):
hey -c 50 -n 10000 http://127.0.0.1:3000
Summary:
Total: 2.7089 secs
Slowest: 0.0387 secs
Fastest: 0.0003 secs
Average: 0.0133 secs
Requests/sec: 3691.5819
Total data: 140000 bytes
Size/request: 14 bytes
Response time histogram:
0.000 [1] |
0.004 [394] |■■■■■
0.008 [990] |■■■■■■■■■■■■■
0.012 [2382] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
0.016 [2998] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
0.020 [2200] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
0.023 [878] |■■■■■■■■■■■■
0.027 [110] |■
0.031 [38] |■
0.035 [7] |
0.039 [2] |
Latency distribution:
10% in 0.0070 secs
25% in 0.0100 secs
50% in 0.0134 secs
75% in 0.0167 secs
90% in 0.0196 secs
95% in 0.0212 secs
99% in 0.0245 secs
Details (average, fastest, slowest):
DNS+dialup: 0.0000 secs, 0.0003 secs, 0.0387 secs
DNS-lookup: 0.0000 secs, 0.0000 secs, 0.0021 secs
req write: 0.0000 secs, 0.0000 secs, 0.0005 secs
resp wait: 0.0133 secs, 0.0003 secs, 0.0387 secs
resp read: 0.0000 secs, 0.0000 secs, 0.0006 secs
Status code distribution:
[200] 10000 responses
Although the numbers are pretty impressive, it’s way more interesting that you won’t recognize any effect on your machine. This is way more interesting once you realize that every HTTP request results in Spin spawning a new instance of our Wasm-module 🤯.
HTTP-Trigger routing capabilities
When we generated Hello World - The Spin Edition with spin new
, we were asked to provide the HTTP base
and the HTTP path
. HTTP base
is configured globally on the scope of the HTTP trigger. In contrast, the HTTP path
is component centric. Meaning we can have multiple components (commonly with different values for HTTP path
as part of one Spin application).
Go and double-check the spin.toml
file. You’ll find the default value for HTTP base
as part of the HTTP-trigger configuration. HTTP path
is located in the component configuration (see [[component]]
).
Besides pinning specific components to dedicated routes (by providing the full path (e.g., api/dashboard
), we can use the spread operator and tell Spin that a particular component will handle all requests having a path starting with a specific value.
[[component]]
id = "products"
source = "target/wasm32-wasi/release/products.wasm"
[component.trigger]
route = "/api/products/..."
The component configuration shown here tells Spin to instantiate and invoke the products component for all requests to /api/products
(including api/products/archived
, api/products/trending/byCategory
, …).
When we register more than one component for the same route, the last component will receive requests at runtime.
Dealing with HTTP methods in Spin
Now that we walked through the basics of Spin and run our very first application, we’re going to extend the sample a bit. We will enhance the Hello World microservice to address the following requirements:
- Accept only HTTP
GET
andPOST
requests - Respond with HTTP
405
when receiving requests using different HTTP methods - Respond with
Hello, <NAME>
where<NAME>
is pulled from the request body (JSON) when receiving an HTTPPOST
request - Respond with
Hello, <NAME>
where<NAME>
is pulled from the query string when receiving an HTTPGET
request - Respond with
Hello, Fermyon
as fallback
Matching HTTP Request methods
We created the Hello World application using Rust, and one of the most amazing features of Rust is pattern matching. We will now leverage pattern matching to quickly respond to requests with HTTP methods other than GET
and POST
by sending an HTTP 405
(Method not allowed):
#[spin_component]
fn hello_world(req: Request) -> Result<Response> {
match req.method() {
&http::Method::GET => {
handle_get(&req)
},
&http::Method::POST => {
handle_post(&req)
}
_ => {
Ok(http::Response::builder()
.status(StatusCode::METHOD_NOT_ALLOWED)
.body(None)?)
}
}
}
Handle GET requests
Let’s implement handle_get
first. We want to pull <NAME>
from the query string (if present) and construct a corresponding HTTP response. Instead of manually parsing the query string, we can use the existing querystring
crate (crates.io/crates/querystring). Update Cargo.toml
and add querystring
as a dependency:
[package]
name = "hello-world"
authors = ["Thorsten Hans <[email protected]>"]
description = "Hello World - The Spin Edition"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = [ "cdylib" ]
[dependencies]
# Useful crate to handle errors.
anyhow = "1"
# Crate to simplify working with bytes.
bytes = "1"
# General-purpose crate with common HTTP types.
http = "0.2"
# The Spin SDK.
spin-sdk = { git = "https://github.com/fermyon/spin", tag = "v0.6.0" }
# Crate that generates Rust Wasm bindings from a WebAssembly interface.
wit-bindgen-rust = { git = "https://github.com/bytecodealliance/wit-bindgen", rev = "cb871cfa1ee460b51eb1d144b175b9aab9c50aba" }
querystring = "1.1"
[workspace]
With the dependencies in place, let’s implement handle_get
:
fn handle_get(req: &Request) -> Result<Response> {
const FALLBACK: &str = "Fermyon";
const NAME_PARAM: &str = "name";
let name = req.uri().query().map_or(FALLBACK, |qs| -> &str {
let params = querystring::querify(qs);
params
.into_iter()
.find(|p| p.0.to_lowercase() == NAME_PARAM)
.map_or(FALLBACK, |p| p.1)
});
Ok(http::Response::builder()
.status(http::StatusCode::OK)
.header("Content-Type", "text")
.body(Some(format!("Hello, {}", name).into()))?)
}
Handle POST requests
Next on our list is handling incoming POST
requests with and grabbing the <NAME>
from the actual JSON payload. Regarding JSON in Rust, there is only one real answer, serde
(crates.io/crates/serde). That said, let’s add serde
and serde_json
as dependencies in Cargo.toml
:
[package]
name = "hello-world"
authors = ["Thorsten Hans <[email protected]>"]
description = "Hello World - The Spin Edition"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = [ "cdylib" ]
[dependencies]
# Useful crate to handle errors.
anyhow = "1"
# Crate to simplify working with bytes.
bytes = "1"
# General-purpose crate with common HTTP types.
http = "0.2"
# The Spin SDK.
spin-sdk = { git = "https://github.com/fermyon/spin", tag = "v0.6.0" }
# Crate that generates Rust Wasm bindings from a WebAssembly interface.
wit-bindgen-rust = { git = "https://github.com/bytecodealliance/wit-bindgen", rev = "cb871cfa1ee460b51eb1d144b175b9aab9c50aba" }
querystring = "1.1"
serde = { features = ["derive" ], version = "1.0"}
serde_json = "1"
Before we implement handle_post
let’s create a simple struct representing the desired shape of our JSON payload:
use serde::Deserialize;
#[derive(Deserialize)]
pub struct HelloWorldRequestModel {
pub name: String,
}
The HelloWorldPostModel
is located in a dedicated file called models.rs
that’s why the access to the type itself is qualified using models::
in the following handle_post
implementation:
fn handle_post(req: &Request) -> Result<Response> {
const FALLBACK: &str = "Fermyon";
let body = req.body().clone().unwrap_or_default();
let name = serde_json::from_slice::<models::HelloWorldRequestModel>(&body)
.map(|p| p.name)
.ok()
.map(|n| {
if n.len() == 0 {
FALLBACK.to_string()
} else {
n.clone()
}
});
match name {
Some(n) => Ok(http::Response::builder()
.status(http::StatusCode::OK)
.header("Content-Type", "text")
.body(Some(format!("Hello, {}", n).into()))?),
None => Ok(http::Response::builder()
.status(http::StatusCode::BAD_REQUEST)
.header("Content-Type", "text")
.body(Some(format!("Please provide a valid JSON payload").into()))?),
}
}
Having all the requirements implemented, we can test our application again. Start the microservice using spin build --up
, and you should again see spin printing essential information about it:
Finished release [optimized] target(s) in 0.04s
Successfully ran the build command for the Spin components.
Serving http://127.0.0.1:3000
Available Routes:
hello-world: http://127.0.0.1:3000 (wildcard)
We can test if our microservice addresses all requirements by sending some slightly different requests to the endpoint:
curl http://localhost:3000\?name\=John
Hello, John
curl http://localhost:3000
Hello, Fermyon
curl -X POST --json '{ "name": "Jane"}' http://localhost:3000
Hello, Jane
curl -X POST --json '{ "name": ""}' http://localhost:3000
Hello, Fermyon
curl -iX POST --json '{ "firstName": "Mike"}' http://localhost:3000
HTTP/1.1 400 Bad Request
content-type: text
content-length: 35
date: Sun, 11 Dec 2022 13:53:41 GMT
Please provide a valid JSON payload
curl -iX DELETE http://localhost:3000
HTTP/1.1 405 Method Not Allowed
content-length: 0
date: Sun, 11 Dec 2022 13:54:11 GMT
Conclusion
WebAssembly on the server and in the cloud will change how we architect our cloud-native applications. Leveraging WebAssembly, we can run microservices with way higher density and address mission-critical concerns like security and isolation by default. With technologies like Spin, we can start the journey to Cloud-Native vNext today and prepare ourselves and our applications for the next big thing on the server side.
If you want to dive deeper into Spin and learn how to build more sophisticated applications, consult the official Spin Documentation and because it’s holiday season 🎄 you may want to participate and solve some challenges as part of the “Advent of Spin”.