TIL Using Erlang Ports
By Sophie DeBenedetto | Posted 2019-04-17
Use Ports and GenServers to communicate from your Elixir app to processes running outside the Erlang VM.
Erlang ports provide us an interface for communicating with external processes by sending and receiving messages. The Elixir Port
module is built on top of Erlang’s ports and makes it easy to start and manage OS processes.
Creating a port to execute a given OS process can be done with the open/2
function:
cmd = "echo hello"
Port.open({:spawn, cmd}, [:binary])
# => #Port<0.5>
Here, we pass open/2
the :spawn
tuple that contains the binary we want to execute over our port. The code above will execute echo hello
on our OS for us.
So, why is this a useful tool?
It’s not too hard to imagine that you might have a program that needs to enact some bit of functionality for which Elixir is not well suited, or for which you already have a script written in some other language. Let’s say that our Elixir app needs to listen to changes in a particular directory and respond by executing some code. We want to leverage fswatch to listen for and report such changes. We can do so with the help of ports!
Starting a Process and Listening for Messages
We’ll use a port to start the fswatch process running. The Elixir process that opens the port is the owner of that port, and will receive messages from the port. Messages will be send from the port to the owner when the process running via the port puts anything to STDOUT.
We’ll define a module FsWatchAdapter
, to open our port and receive messages from it. Our module will use GenServer
so that it can receive messages from the port and act on them.
defmodule FsWatchAdapter do
use GenServer
def start_link(dir) do
GenServer.start_link(__MODULE__, dir)
end
def init(dir) do
state = %{
port: nil,
dir: dir
}
{:ok, state, {:continue, :start_fswatch}}
end
def handle_continue(:start_fswatch, state = %{dir: dir}) do
cmd = "fswatch #{dir}"
port = Port.open({:spawn, cmd}, [:binary, :exit_status])
state = Map.put(state, :port, port)
{:noreply, state}
end
def handle_info({port, {:data, msg}}, state) do
IO.puts "Received message from port: #{msg}"
{:noreply, state}
end
end
Here, we start our GenServer with an argument of the directory we want to watch. We use the handle_continue/2
function to start fswatch over a port. Then we store the port in our GenServer’s state for later use.
Lastly, we define a handle_info/2
function that knows how to respond to the message that the GenServer process will receive from the port, when the fswatch process puts something to STDOUT.
Let’s see our code in action! You can test this out by
-
Copying and pasting the module into an
iex
console. - In iex:
iex> FsWatchAdapter.start_link("~/Desktop")
- Create a new file, “testing-ports.txt” on your Desktop
-
You should see the following in the
iex
console:
iex> Received message from port: "/Desktop/testing-ports.txt"
In order to terminate our fswatch process, we simply need to terminate our GenServer process. Since our FsWatchAdapter
is the port owner, terminating it will terminate the process executing in the port in opened.
Conclusion
Ports are a convenient way to pass messages between your Elixir code and any external process. By leveraging GenServers, we can build a communication mechanism that allows our app to send, receive and respond to messages from external processes. You can learn more about Elixir ports here and more about Erlang ports here.