A couple of weeks ago, I showed a little http
backend. Well,
tiny is probably a more apt description, and also aptly describes its functionality,
which is almost non-existent. All it does is define a simplistic
Task
class, create an array with two
sample instances and then serves that array of tasks over http. And it serves the
-description
of those tasks
rather than anything usefuk like a JSON encoding.
For reference, this is the original code, hacked up in maybe 15 minutes:
#!env stsh
framework:ObjectiveHTTPD load.
class Task {
var <bool> done.
var title.
-description { "Task: {this:title} done: {this:done}". }
}
taskList ← #( #Task{ #title: 'Clean my room', #done: false }, #Task{ #title: 'Check twitter feed', #done: true } ).
scheme todo {
var taskList.
/tasks {
|= {
this:taskList.
}
}
}.
todo := #todo{ #taskList: taskList }.
server := #MPWSchemeHttpServer{ #scheme: todo, #port: 8082 }.
server start.
shell runInteractiveLoop.
What would it take to make this borderline useful? First, we would probably need to encode the result as JSON, rather than
serving a description. This is where Storage Combinators come in. We (now) have a
MPWJSONConverterStore
that's
a mapping store, it passes its "REST" requests through while performing certain transformations on the data and/or the
references. In this case the transformation is serializing or deserialzing objects from/to JSON, depending on which
way the request is going and which way the converter is pointing.
In this case, the converter is pointing "up", that is it serializes objects read from its source to JSON and deserializes
data written to its source from JSON to objects. We also tell it that it is dealing with Task
objects.
When we have the converter we connect it to our todo
scheme and tell the HTTP server to talk to the json
converter (which talks to our todo scheme):
todo := #todo{ #taskList: taskList, #store: persistence }.
json := #MPWJSONConverterStore{ #up: true, #class: class:Task }.
json → todo.
server := #MPWSchemeHttpServer{ #scheme: json, #port: 8082 }.
Second, we also want to be to interact with individual tasks. No problem, just add a
/task/:id
proprerty path to our store/scheme handler,
along with GET ("|=") and PUT ("=|") handlers. I am not fully sold yet on the "|=" syntax for this, but I would like to avoid names for this sort of
structural component. Maybe arrows?
/task/:id {
|= {
this:taskDict at:id .
}
=| {
this:taskDict at:id put:newValue.
}
In order to facilitate this, the
taskList
was changed to a dictionary. Once we make changes to our data, we probably also want to persist it.
One easy way to do this is to store the tasks as JSON on disk. This allows us to reuse the JSON converter from above, but this time pointing "down". We
connect this converter to the filesystem at the directory
/tmp/tasks
and to the store:
json → todo → #MPWJSONConverterStore{ #class: class:Task } → ref:file:/tmp/tasks/ asScheme.
In addition, we need to trigger saving in the PUT handler:
=| {
this:taskDict at:id put:newValue.
self persist.
}
-persist {
source:tasks := this:taskDict allValues.
}
}
This will (synchronously) write the entire task list on every PUT.
The full code is here:
#!env stsh
framework:ObjectiveHTTPD load.
class Task {
var id.
var done.
var title.
-description { "Task: {this:title} done: {this:done} id: {this:id}". }
-writeOnJSONStream:aStream {
aStream writeDictionaryLikeObject:self withContentBlock:{ :writer |
writer writeInteger: this:id forKey:'id'.
writer writeString: this:title forKey:'title'.
writer writeInteger: this:done forKey:'done'.
}.
}
}
taskList ← #( #Task{ #id: '1', #title: 'Clean Room', #done: false }, #Task{ #id: '2', #title: 'Check Twitter', #done: true } ).
scheme todo : MPWMappingStore {
var taskDict.
-setTaskList:aList {
this:taskDict := NSMutableDictionary dictionaryWithObjects: aList forKeys: aList collect id.
}
/tasks {
|= {
this:taskDict allValues.
}
}
/task/:id {
|= {
this:taskDict at:id .
}
=| {
this:taskDict at:id put:newValue.
self persist.
}
}
-persist {
source:tasks := this:taskDict allValues.
}
}.
todo := #todo{ #taskList: taskList }.
json := #MPWJSONConverterStore{ #up: true, #class: class:Task }.
json → todo → #MPWJSONConverterStore{ #class: class:Task } → ref:file:/tmp/tasks/ asScheme.
server := #MPWSchemeHttpServer{ #scheme: json, #port: 8082 }.
server start.
shell runInteractiveLoop.
The
writeOnJSONStream:
method is currently still needed by the serializer to encode the task object as JSON. The parser doesn't need
any support, it can figure things out by itself for simple mappings. Yes, this makes no sense, as serializing is easier than parsing, but I
haven't gotten around to the automation for serializing yet.
Analysis
So there you have it, an almost functional Todo backend, in refreshingly little code, and with refreshingly little magic.
What I find particularly pleasing is that this conciseness can be achieved while keeping the architecture fully visible
and maintaining a hexagonal/ports-and-adapters style.
What is the architecture of this app? It says so right at the end: the server is parametrized by its scheme, and that scheme
is a JSON serializer hooked up to my todo scheme handler, hooked up to another JSON serializer hooked up to the directory /tmp/tasks
.
Although a Rails app contains comparably little code, this code is scattered over different classes and is only comprehensible as a
plugin to Rails. All the architecture is hidden inside Rails, it is not at all visible in the code and simply cannot be
divined from looking at the code. Although there are many reasons for this, one fundamental one is that Ruby is a call/return
language, and Rails does its best to translate from the REST architectural style to something that is more natural in the
call/return style. And it does an admirable job at it.
I do think that this example gives us a little glimpse into what I believe to be the power of Architecture Oriented Programming: the
power and succinctness of frameworks, but with the simplicity, straightforwardness and reusability of more library-oriented styles.
Performance
I obviously couldn't resist benchmarking this, and to my great joy found that
wrk now
works on the M1. Since the interpreter isn't thread safe, I had to restrict it to a single connection and thread. My
expectations were that it requests/s would be in the double to low triple digits, my fear was that it would be single
digits. (The reason for that fear is the
writeOnJSONStream:
method that is called for every object
serialized and is in interpreted Objective-S, probably one of the slowest language implementations currently in existence).
To say I was surprised is an understatement. Stunned is more like it:
wrk -c 1 -t 1 http://localhost:8082/task/1
Running 10s test @ http://localhost:8082/task/1
1 threads and 1 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 133.62us 14.45us 0.97ms 98.52%
Req/Sec 7.50k 311.09 7.62k 99.01%
75326 requests in 10.10s, 12.28MB read
Requests/sec: 7458.60
Transfer/sec: 1.22MBTransfer/sec: 1.97MB
More than 7K requests per second! Those M1 Macs really are fast. I wonder what it will be once I remove the need for the manually
written
writeOnJSONStream:
method.
(NOTE: previous version said >12K requests/s, which is even more insane, but was with an incorrect URL that had the server returning 404s)