DEV Community

Cover image for Unison: From 0 to Cloud
Zelenya
Zelenya

Posted on

Unison: From 0 to Cloud

📹 Hate reading articles? Check out the complementary video, which covers the same content.


In 2 minutes

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 

  ...
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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/
...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

https://zelenya.unison-services.cloud/s/hello-world/Joseph:

👋 Hello, Joseph 👋
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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"
Enter fullscreen mode Exit fullscreen mode

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: 
...
Enter fullscreen mode Exit fullscreen mode
ambitious-porcupine/main> run examples.helloWorld.deploy
...
Service with hash: yt4xnmaxvjlg4enlmatsv57d5u6hffar74pfty4uephgav42eaxq now available at:
... 
Enter fullscreen mode Exit fullscreen mode

💡 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
Enter fullscreen mode Exit fullscreen mode

Unison parses it as a syntax tree:

increment = (#arg1 -> #a8s6df921a8 #arg1 1)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

💡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)
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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" 
Enter fullscreen mode Exit fullscreen mode

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…


Top comments (0)