goer Download ↓

Tutorial 04 / 15 minutes

Use a port for the outside world.

Sooner or later your process (one of Goer’s small private workers) needs to read a file, call an API, or charge a card. Those are the slow, flaky parts of any program, and they’re also what makes code hard to test. imgo, the cut-down Go that processes are written in, can’t do any of them directly, and that’s on purpose. In this tutorial you’ll learn the one way out: a port. You’ll set one up, call it two different ways, and see why the detour pays for itself.

What’s a port?

A port is regular Go code that looks like a process from the inside. Your process sends it a request. The Go code does the real work. Then it sends an answer back.

Think of the made-up shop again. The clerk never leaves the counter to fetch stock. They hand a slip through a hatch and someone in the back room fetches it. The hatch is the port. And while one clerk waits at the hatch, the other clerks carry on.

process→ requestport→ replyprocess

1. Set up a port in Go

A port is a Go function that takes a request and returns a reply. You give it a name. The request shows up as a msg.Spec: the message’s type name and its fields.

main Go package
sys.RegisterPort("notifications", func(req msg.Spec) msg.Spec {
    orderID, _ := req.Get("OrderID")
    message, _ := req.Get("Message")
    fmt.Printf("notify %v: %v\n", orderID.S, message.S)
    return msg.Spec{Type: "Notified"}
})

The fields are a list. req.Get("Name") finds one, and its value is in .S, .I, .F or .B depending on whether it’s a string, integer, float or bool.

Goer comes with a ready-made file port. ports.FS() handles ReadFile and WriteFile; register it as "fs" the same way. One thing to know: every process on the node can reach a port by its name. If that’s more access than you want, use ports.FSRoot(dir), which keeps every path inside one folder.

2. Send a request and get the reply as a message

In imgo, Port("name") gives you the port’s address and <- sends to it. Put ReplyTo: Self() in the request if you want an answer. The answer arrives later as a normal message, so you declare a struct for it and handle it in the same switch.

reader_actor.go
type ReadFile struct {
    Path    string
    ReplyTo Pid
}
type FileData struct {
    Path string
    Data string
}

func Reader(s ReaderState, m Msg) ReaderState {
    switch x := m.(type) {
    case Open:
        Port("fs") <- ReadFile{Path: x.Path, ReplyTo: Self()}
        s.Status = "waiting"
    case FileData:
        s.Bytes = len(x.Data)
        s.Status = "loaded"
    }
    return s
}

3. Or just ask and wait

Sometimes the next line of code needs the answer, and splitting the logic across two message cases only makes it harder to read. Ask sends the request and pauses only this process until the reply lands. Everyone else keeps running. Messages that arrive in the meantime wait their turn.

order_actor.go
case StockReserved:
    payment := Ask(Port("payments"), AuthorizePayment{
        OrderID: s.OrderID, AmountCents: s.AmountCents,
    }).(PaymentResult)
    if payment.Approved {
        s.Status = "fulfilling"
    }

A process waiting in Ask shows up in the dashboard with the exact line it’s stuck on. That makes a slow port easy to spot.

Why bother with all this?Because it makes testing easy. Register a fake port that writes down what it was asked and returns canned answers, and your process never touches a real file or network. And a process that was never told a port’s name simply can’t reach that thing.

4. One request, many replies

Some jobs answer in pieces: tailing a log, reading a big file in chunks, following a feed. For those, use RegisterPortStream. Your handler gets a send function and can call it as many times as it likes.

When the handler returns, Goer sends one last message, PortStreamEnd. Its Err field holds the handler’s error if there was one (even if it panicked), and Count says how many replies went out. So the caller always finds out when it’s over.

main Go package · a streaming port
sys.RegisterPortStream("pulse", func(req msg.Spec, send func(msg.Spec) error) error {
    nf, _ := req.Get("N")
    for i := int64(0); i < nf.I; i++ {
        if err := send(msg.Spec{Type: "Beat", Fields: []msg.Field{
            msg.Int("I", i),
        }}); err != nil {
            return err // caller’s mailbox is full: stop, don’t block
        }
    }
    return nil
})

On the process side there’s nothing new: send the request with ReplyTo: Self(), then handle the chunk messages and the end message in your switch. Don’t use Ask here, because Ask waits for exactly one reply.

What if the process can’t keep up? send doesn’t wait around. If the mailbox is full it returns ErrMailboxFull, and your handler decides whether to stop or try again.

listener_actor.go
type Pulse struct {
    N       int
    ReplyTo Pid
}
type Beat struct {
    I int
}
type PortStreamEnd struct {
    Err   string
    Count int
}

func PulseListener(s PulseState, m Msg) PulseState {
    switch x := m.(type) {
    case Go:
        Port("pulse") <- Pulse{N: x.N, ReplyTo: Self()}
        s.Beats = 0
    case Beat:
        s.Beats = s.Beats + 1
    case PortStreamEnd:
        s.Done = x.Err == ""
    }
    return s
}

5. Binary files

The file port can also read and write raw bytes: ReadFileBytes answers with FileBytes{Path, Data, Err}, and WriteFileBytes answers with WriteDone{Path, Err}. The bytes travel as a string field. That’s fine, because a Go string can hold any bytes at all, so nothing gets changed on the way.

In imgo you can declare the field as []byte or string; they’re the same thing on the wire. In Go, ports.FieldBytes turns the field back into a real []byte.

copier_actor.go
type CopyFile struct {
    From string
    To   string
}
type ReadFileBytes struct {
    Path    string
    ReplyTo Pid
}
type FileBytes struct {
    Path string
    Data []byte
    Err  string
}
type WriteFileBytes struct {
    Path    string
    Data    []byte
    ReplyTo Pid
}
type WriteDone struct {
    Path string
    Err  string
}

func Copier(s CopierState, m Msg) CopierState {
    switch x := m.(type) {
    case CopyFile:
        Port("fs") <- ReadFileBytes{Path: x.From, ReplyTo: Self()}
    case FileBytes:
        if x.Err != "" {
            panic("read failed: " + x.Err)
        }
        Port("fs") <- WriteFileBytes{Path: x.To, Data: x.Data, ReplyTo: Self()}
    case WriteDone:
        s.Status = "copied " + x.Path
    }
    return s
}