đšÂ Hate reading articles? Check out the complementary video, which covers the same content.
Letâs cut to the chase. Letâs deploy something. Currently, I have no project. I have Unison language installed (which I brew installed on a mac) and a Unison account (I one-clicked signed-in with GitHub).
đĄÂ We can use a Unison account with Unison Share and Unison Cloud.
First, we start Unison Codebase Manager by running ucm
.
đĄ The UCM is the interface to the Unison codebase
Then we type projects
to list all the projects and see nothing. We can create a new project with project.create-empty
:
.> projects
.> project.create-empty
đ I've created the project with the randomly-chosen name ambitious-porcupine
...
Instead of constructing a whole project by hand, we can use some templates. We fetch a few simple examples and required dependencies with pull @unison/cloud-start/releases/latest
:
ambitious-porcupine/main> pull @unison/cloud-start/releases/latest
...
â
Successfully pulled into ambitious-porcupine/main, which was empty.
Right after, we can deploy a hello-world project:
ambitious-porcupine/main> run examples.helloWorld.deploy
Service exposed successfully at:
https://zelenya.unison-services.cloud/h/wqcwa3uyfsxqrle3ut7fhbfno4qkkqdy2flspsnbqsfeb62m5cgq/
Service with hash: wqcwa3uyfsxqrle3ut7fhbfno4qkkqdy2flspsnbqsfeb62m5cgq now available at:
https://zelenya.unison-services.cloud/s/hello-world/
...
When we open one of these urls with a name as a path parameter, for example, https://zelenya.unison-services.cloud/s/hello-world/Joseph, we see a greeting:
đ hello Joseph
For those who arenât convinced or those who want to see the hello capitalized and with a comma, we can edit the logic really quickly:
ambitious-porcupine/main> edit examples.helloWorld.logic
I added these definitions to the top of .../scratch.u
...
You can edit them there, then do `update` to replace the definitions currently in this namespace.
Letâs jump over these details for now and just modify the logic in the scratch.u
file, named this way because itâs meant to be thrown away.
The service's logic is a function that accepts an HttpRequest
and returns an HttpResponse
.
helloWorld.logic : HttpRequest ->{Exception, Log} HttpResponse
helloWorld.logic = Route.run do
use Text ++
name = route GET Parser.text
info "request for greeting" [("name", name)]
ok.text ("đ hello " ++ name ++ "\n")
To modify this request body, we change the last line:
helloWorld.logic : HttpRequest ->{Exception, Log} HttpResponse
helloWorld.logic = Route.run do
use Text ++
name = route GET Parser.text
info "request for greeting" [("name", name)]
ok.text ("đ Hello, " ++ name ++ " đ \n") -- HERE
Because ucm
is listening for changes in the current file, when we save it, Unison parses and typechecks it:
ambitious-porcupine/main>
I found and typechecked these definitions in .../scratch.u. If you do an `add` or
`update`, here's how your codebase would change:
â These names already exist. You can `update` them to your new definition:
examples.helloWorld.logic : HttpRequest ->{Exception, cloud_6_0_6.Log} HttpResponse
We can update the definition (the hello worldâs logic) using update
as ucm
suggests:
ambitious-porcupine/main> update
Okay, I'm searching the branch for code that needs to be updated...
...
Done.
If we redeploy again and check the response, we get an updated text:
ambitious-porcupine/main> run examples.helloWorld.deploy
Service exposed successfully at:
https://zelenya.unison-services.cloud/h/yt4xnmaxvjlg4enlmatsv57d5u6hffar74pfty4uephgav42eaxq/
Service with hash: yt4xnmaxvjlg4enlmatsv57d5u6hffar74pfty4uephgav42eaxq now available at:
https://zelenya.unison-services.cloud/s/hello-world/
https://zelenya.unison-services.cloud/s/hello-world/Joseph:
đ Hello, Joseph đ
Okay, we saw that the serviceâs logic is a regular function. But what about the deployment? Clearly, all the complexity hides there.
Hold on to your socks, folks! Deploy is just a function too:
ambitious-porcupine/main> view examples.helloWorld.deploy
examples.helloWorld.deploy : '{IO, Exception} ()
examples.helloWorld.deploy = Cloud.main do
name = ServiceName.create "hello-world"
serviceHash = deployHttp !Environment.default helloWorld.logic
ignore (ServiceName.assign name serviceHash)
printLine "Logs available at:\n https://app.unison.cloud"
This function deploys an http service with helloWorld.logic
we just saw, while we just sit there and enjoy.
Painless deployments?
Only functions? Yes! There are no YAML files, no packaging, no containers, or anything like that.
You call a function to deploy a service! Unison takes care of the dependencies, caching on the server, and so on. Deployment takes seconds.
How is this even possible?
You might have noticed the unusual urls and plain hashes associated with services:
ambitious-porcupine/main> run examples.helloWorld.deploy
...
Service with hash: wqcwa3uyfsxqrle3ut7fhbfno4qkkqdy2flspsnbqsfeb62m5cgq now available at:
...
ambitious-porcupine/main> run examples.helloWorld.deploy
...
Service with hash: yt4xnmaxvjlg4enlmatsv57d5u6hffar74pfty4uephgav42eaxq now available at:
...
đĄÂ The default service URLs are based on the service hash (see ServiceHash).
This hash is a hash of the service implementation! In other words, Unison takes the source code of the service with its metadata and makes a unique identifier out of it.
A hash identifies a service and a service is immutable. When we change the logic, we don't modify a service, instead, we deploy a new one, and we get a new hash based on the serviceâs implementation.
Content-addressing
This concept, content-addressing, is a cornerstone for Unison. And it applies to any piece of code. The Unison code is identified by its content and not by its location (like we would typically think about code). Letâs unroll this.
With a typical programming language, when you write some code, you modify some text on a specific line in a specific file.
This is not the case with Unison â Unison code doesnât even live in a text-based file. We can still modify it using the text editor (as weâve seen) but we have to pass this code to the tool such as ucm
that does the actual change. Letâs look at an example. Given a function:
increment : Nat -> Nat
increment n = n + 1
Unison parses it as a syntax tree:
increment = (#arg1 -> #a8s6df921a8 #arg1 1)
And then hashes it and stores it in the database.
The human-readable names, such as increment
and +
, is metadata that doesnât affect the functionâs hash and is stored separately.
Because itâs so important, letâs look at another example. Letâs try to define a new type that represents a box that optionally has a value (something known as Option
or Maybe
in other languages), which consists of two variants:
-
Emptiness
, which represents a lack of value. -
Exists
, which wraps a value of any type.
structural type MyBox a
= Emptiness
| Exists a
đĄ
structural
means that the types defined with the same structure (or shape) are identical.
When we save the scratch file, the ucm
tells us that this definition already exists and is also named lib.base.Optional
! If we check the implementations or the hashes, we can see that we just have different names for the same type. Itâs pretty fun.
But itâs not just about fun.
Benefits
Having content-addressed code brings tons of other benefits and changes the way we work.
It starts with simple niceties such as instant renames (because itâs just a metadata change in a database, not a complicated semantic analysis that has to be done when you refactor a name with mainstream tooling).
It also means no builds and no test reruns (Unison needs to hash your function just once â if you donât change its implementation it doesnât need to redo anything, furthermore it knows that there is no need to rerun the tests), no dependency hell nor version conflicts (if you have two âdifferent versionsâ of a function brought by dependencies it means that you have two different functions, and you can either unify the usage or keep using two functions), and so on.
It shines the most when it comes to distributed programming. Unison is designed with distributed computing in mind. We can move computations from one location to another, and the receiver either has all the hashes already and can perform the computation, or it has to request and sync the missing dependencies (which then will be cached).
Unison handles serialization and deserialization of data, which makes it easier to send data over the network. Letâs talk about that in detail.
Painless RPCs
You can call other services and storages as smoothly as local functions (no need to convert the data from and to json, protobuf, or whatever).
Letâs just try it out. examples.multiService.deploy
demonstrates a multi-service deployment and usage. This time, letâs view it in ui
(or you can look at Unison Share).
We start by deploying two services: the first one takes a natural number and returns it incremented by one, and the second â decrements by one.
h1 = deploy! default (x -> x + 1)
incrService = create "increment"
ignore (assign incrService h1)
We use the deploy
function to deploy a service, which takes an environment along with a function of the shape a ->{abilities} b
(donât worry about these abilities for now) and returns a ServiceHash a b
, where a
is the input type of the service and b
is the output type.
Then we create the edge
service, which is going to be a public-facing gateway service that speaks HTTP, deals with the real world, and delegates to the two typed services we just saw.
edge : HttpRequest -> HttpResponse
edge =
...
incr = do
n = route GET (s "increment" / nat)
n' = callName incrService n
text (toText n')
decr = do
...
Route.run (incr <|> decr)
n = create "counter-edge-service"
ignore (assign n (deployHttp !default edge))
Note that we deploy this service with deployHttp
(not generic deploy
) because its input type is HttpRequest
and output type is HttpResponse
.
We use callName
to call other Unison service (it takes a service name and an input):
callName : ServiceName a b -> a ->{Services, Remote} b
If we try to change the code to pass an argument of the wrong type (for example a string instead of a natural number), it wonât typecheck:
The 2nd argument to `callName`
has type: Text
but I expected: Nat
26 | n' = callName incrService "string, not natural"
We donât have to deal with serialization code and we can never send the wrong blob of data to a service.
Everything is a function
Letâs do a quick recap, in Unison world:
- Deployment is done with a function call.
- Calling another service is a function call.
- Accessing storage is a function call.
- A function call is a function call (just for completeness đ ).
đĄÂ Weâre not going to look at the example of using typed durable storage in this tutorial. The real fun is trying it out yourself; not going to take all the fun away from you.
In summary
Unison is already out there, Unison Cloud is either in public beta already or shortly there depending on when youâre watching/reading this. See the attached links and ask around.
I donât know about you, but Iâm looking forward to a future with no yamls, packaging, containers, version conflicts, json shuffling⌠Well, probably the last one is unavoidable, you still have to talk to the outside world, but still.
Less of all of that, and more coding â sounds like a good deal to me. Now, the only thing thatâs left is to figure out how to replace meetings with unisonâŚ