UDP Socket Programming: Node vs. Go

Reader beware: this is a code-heavy post.

Overview

As I’ve outlined in my previous post, we are building a system called Roadomatic for our senior project in Electrical Engineering at UAE University. The goal of the system is to enable delivery of location-based road and traffic information to drivers in real-time.

Roadomatic consists of two main components: a client application targeting the Android operating system, and a server written in Node implemented by yours truly.

From the very start, our number one priority has been efficiency. For example, the system’s communication protocol consists of JSON on top of UDP, allowing for easy encoding and decoding on both ends, as well as data usage on the order of 200 bytes/exchange.

In this post, I’ll be talking mainly about the backend, and specifically comparing two functionally identical implementations of the server I’ve written in Node and Go.

Server Operation

The backend consists of two main parts:

The general flow of the server operation is as follows:

  1. A UDP datagram is received on the socket.
  2. Datagram contents are decoded using JSON, and geographic coordinates are stored.
  3. The database is queried to find the road the received coordinate is on.
  4. The response object is built, then encoded using JSON.
  5. A datagram containing the response bytes is sent back over the wire.

As you can see, server-side processing is minimal in the system’s current state. However, we might opt to defer more processing to the server in future changes.

But how is this processing done in each implementation? And how difficult were the two implementations relative to each other?

Round 1: Request Processing

One of the reasons behind why we initially chose to transmit data as JSON was the technology stack we used to build the backend: Node and MongoDB. Both of these rely heavily on JSON (or BSON for the pedants). So as expected, working with data and querying the database were very easy to achieve in Node.

However, achieving the same in Go wasn’t that much more complicated. I was pleasantly surprised by how easy many “complex” operations were in Go, given that it’s a comparatively low-level language.

As for the UDP server itself, both languages have standard libraries that simplify the process immensely.

UDP Socket

To start off, we’ll compare between the two languages when it comes to listening on a port and executing code when a datagram is received.

// Socket setup
var socket = dgram.createSocket('udp4');
socket.bind(config.server_port, config.server_host);

socket.on('listening', () => {
  console.log('Listening on %s:%d...', config.server_host, config.server_port);
});

socket.on('message', (req, remote) => {
  console.log('Message #%d received!', (++c));

  processRequest(req, remote, socket);
});

socket.on('error', (error) => {
  console.log(error.stack);
  socket.close();
});

// Request handler
var processRequest = (req, remote, socket) => {
  // ...
}

Let’s see how it’s done in Go:

func main() {
  // Socket setup
  addr := net.UDPAddr{
    Port: PORT,
    IP: net.ParseIP(HOST),
  }

  conn, err := net.ListenUDP("udp", &addr)
  if (err != nil) {
    panic(err)
  }
  defer conn.Close()

  for {
    // Create a buffer for each request
    buf := make([]byte, 1024)

    // Read bytes into buffer (blocking)
    b, addr, err := conn.ReadFromUDP(buf)
    if (err != nil) {
      panic(err)
    }

    // Spawn a goroutine for each request
    go udpHandler(buf, b, n, conn, addr)
  }
}

func udpHandler(buf []byte, b int, ...) {
  // ...
}

Request Validation

Next, let’s look at how checking the validity of a request is implemented.

In both cases we’re continuing from the end of the two snippets shown above.

For Node, below is the code responsible for checking whether or not the received request Buffer is valid:

var processRequest = (...) => {
  try {
    parsed = JSON.parse(req);

    // Check for correct params
    if (!parsed.lat || !parsed.lng) {
      throw Error();
    }
  } catch (e) {
    console.log(e.stack);
    valid = false;
  }

  if (valid) {
    // Continue processing
  }
}

In Go, we’re checking a []byte instead of a Buffer, but the idea is the same:

type Request struct {
  Lat float64 `json:"lat"`
  Lng float64 `json:"lng"`
}

func udpHandler(...) {
  // Slice buffer depending on read bytes, trim spaces
  clean := bytes.TrimSpace(buf[:b])

  // Parse received JSON
  r := Request{}

  err := json.Unmarshal(clean, &r)
  if err != nil {
    fmt.Println(err)
    return
  }

  // Continue processing
}

Result: a tie

The line count was similar, and I think both versions are clear even for someone who’s not too familiar with either Node or Go.

Round 2: Querying the Database

This is the part where we expect Node to shine given its native support for JSON and asynchronous programming style.

First, a quick overview of what the code needs to do. We need to make two find queries to the database.

The first is to determine the road segment the given coordinate lies on using MongoDB’s geospatial functionality and GeoJSON.

The second query is to extract further information about the road in general, specifically the name of the street, using the road_id field stored with each segment.

The Node implementation uses the mongodb package, whilst the Go version depends on mgo.

var findRoad = (parsed, db, callback) => {
  const resp = newResponse();

  // GeoJSON coordinate representation
  const loc = {
    type: 'Point',
    coordinates: [parsed.lng, parsed.lat]
  };

  var segments = db.collection(config.segments);

  // Perform query on SEGMENTS collection
  // Find the road segment that contains Point `loc`
  const q = {
    shape: {
      $geoIntersects: {
        $geometry: loc
      }
    }
  };

  segments.findOne(..., (err, segment) => {
    if (err) {
      // Collection read error
      console.log(err);
      resp.online = 0;
      callback(resp);
    } else if (!segment) {
      // No segment match!
      callback(resp);
    } else {
      // Segment match!
      var roads = db.collection(config.roads);

      // Find road name using road_id
      roads.findOne(..., (err, road) => {
        if (err || !road) {
          // Read error, return what we have
          console.log(err);
          resp.name = "";
        } else {
          resp.found = 1;
          resp.speed = segment.speed;
          resp.name = road.name;
        }

        callback(resp);
      });
    }
  });
};

Well… I don’t really think async makes things clean.

Let’s look at how we did the same in Go.

func findRoad(req *Request) Response  {
  r := Response{Online: 1}

  lat, lng := req.Lat, req.Lng
  loc := bson.M{
    "type": "Point",
    "coordinates": []float64{lng, lat},
  }

  // Query database, retrieve road_id
  session, err := mgo.Dial(fmt.Sprintf("mongodb://%v:%d", MONGO_HOST, MONGO_PORT))
  if err != nil {
    r.Online = 0
    fmt.Println(err)
    return r
  }
  defer session.Close()

  segments := session.DB(MONGO_DBNAME).C(MONGO_SEGMENTS)
  s := Segment{}

  filter := bson.M{"_id": 0, "speed": 1, "road_id": 1}
  query := bson.M{
    "shape": bson.M{
      "$geoIntersects": bson.M{
        "$geometry": loc,
      },
    },
  }

  err = segments.Find(query).Select(filter).One(&s)
  if err != nil {
    fmt.Println(err)
    return r
  }

  roads := session.DB(MONGO_DBNAME).C(MONGO_ROADS)
  road := Road{}

  err = roads.Find(bson.M{"_id": s.RoadId}).One(&road)
  if err != nil {
    r.Online = 0
    fmt.Println(err)
    return r
  }

  r.Found = 1
  r.Speed = s.Speed
  r.Road = road.Name

  return r
}

Result: Go wins

I’m no Go expert, but I believe such examples demonstrate how Go’s approach to error checking really shines. For me at least, the Go version looks much cleaner.

Of course, if you think the Node version could have been written in a more elegant manner, please don’t hesitate to leave a comment below. Or better yet, send us a PR on Github!

Update: JakeChampion submitted an awesome PR that helped us migrate the function above from callbacks to Promises! It’s cleaner now, but I still like the Go version more :P

Round 3: Performance

Here is a table outlining the results of testing. Please keep in mind that response time includes RTT from client (located in the UAE) to server (located in the Netherlands).

Memory Usage Response Time Compiled Size Dependencies
Node 25 MB ~150 ms No N/A mongodb
Go ~2 MB 150 ms Yes 6 MB mgo (linked)

Result: Go wins

Go clearly is the winner here, by a large margin.

The memory usage of the Go implementation is simply astounding, allowing the backend to run on a constrained server. In our case, we’re running the Node version on a 512 MB box, and memory usage is almost at the edge. Given MongoDB’s memory requirements, it’s good to shave off some MBs when given the chance.

Conclusion

In summary, both Node and Go are excellent choices for writing server-based applications. However, for our specific use-case, Go is simply superior in almost every way.

We are currently still using the Node implementation for testing, but we will likely shift to the Go version if we end up deploying Roadomatic in production.

In any case, porting the system from Node to Go was an excellent experience for me personally. I’ve always wanted to learn Go, and this was the best kind of excuse to just do it.

Discussion