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.
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.
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.
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.
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.
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.
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.
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.
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
}