In a recent post I talked about sockethelpers and the kinds of problem it aims to help with. Service discovery is the first aspect of the library I've been working on, and that's the topic of this post. The code discussed here is still very alpha - but it's ready for you to give a try on github.
The challenge
Having a PCL-based socket library is good start - it allows us to code communication between a diverse set of platforms directly in shared code. However, in most scenarios, before they can begin communicating, they need to connect to one another. Connecting to another device over an IPv4 socket requires knowledge of the target IP address and port.
Imagine two people in a room who want to play a simple multiplayer game together. One player will host, and one will join - the joining device must know the address and port in order to make the connect. The easy approach would be to show the host their own IP address, and have them communicate it to the joining player, who could then type it in. That is not going to delight our users. If instead we have a group of five players looking to join, they each have to type it in. What could possibly go wrong?
We Can Do Betterâ„¢
Enter the concept of service discovery - a means for a client to request information about the hosts or services available to it, without requiring upfront knowledge of the specific addresses at which they might be listening. On receipt of a discovery request, the host can decide whether to respond, and if so, can send service information back to the client so that it can initiate a formal connection.
The sockethelpers library - aiming to be opinionated but still flexible - approaches this problem in the following manner.
ServicePublisher and ServiceDiscoverer
ServicePublisher
and ServiceDiscoverer
instances are provided by the library, and provide the plumbing required to work with a an IServiceDefinition
. Given a host and client sharing a definition (read: discovery protocol), the ServicePublisher
calls Publish()
when it is ready to advertise its availability and a ServiceDiscoverer
calls StartDiscovering
to determine what is available. StartDiscovering
broadcasts requests at the rate specified by DiscoveryInterval
, sending data determined by the protocol defined in the service definition. Service responses are exposed via DiscoveredServices
, an IObservable<TPayloadFormat>
that fires for each response received. Exposing services via an IObservable
gives you the opportunity to use Rx operators to concisely specify time-oriented logic to the responses received. For example, given you are likely to be updating the UI with received service data, you may like to batch your updates using Buffer
to avoid constant marshalling.
The type parameters TSeekFormat
and TPayloadFormat
govern the data type of the request and reponse messages. In its most basic form - given IServiceDefinition
- these are both byte[]
. However, the goal here is to make this simple - you needn't implement IServiceDefinition
directly.
IServiceDefinition and friends
IServiceDefinition
is all about defining the sequence of bytes that should be sent to discover a service, and about defining the response (if any) that should be sent when a given request sequence of bytes is received. I say bytes - because that's what gets sent down the wire - but we don't actually want to work with bytes most of the time. Developers are people too! To show how we can extend this, I've built out a few levels of abstraction:
TypedServiceDefinition
and onwards save you the trouble of working directly with byte[]
s in your calling code. You have a choice of what point in the chain to work at. In all cases you implement DiscoveryRequest
- a function which returns a TSeekFormat
representing your service discovery request, and ResponseFor(TSeekFormat request)
- a function that returns a response of type TPayloadFormat
(or nothing) based on the request that was received. Of course, at runtime, clients use ServiceDiscoverer
which will execute the DiscoveryRequest
code, whereas hosts use ServicePublisher
, which will execute the ResponseFor
code.
Deriving from
TypedServiceDefinition
lets your calling code work withTSeekFormat
andTPayloadFormat
instances. You must implement four methods on top of the ones mentioned above -RequestToBytes
,BytesToRequest
,PayloadToBytes
,BytesToPlayload
- these implement the conversion of your POCOs into thebyte[]
s that are required byIServiceDefinition
. Essentially, here you retain control over the serialisation method. For example, if you annotated your request and response models, you could use protobuf-net to handle the conversions.Deriving from
JsonSerializedServiceDefinition
gives you a service definition that implements the abovementioned conversion functions using JSON.NET. For the types of objects you would expect to hand back and forth in a discovery exchange, this should be a comprehensive, no hassle solutionLastly
FuncyJsonServiceDefinition
(name subject to serious reconsideration) is a concrete class born from my love of object initializer syntax and irrational desire to avoid subclassing. You don't need to inherit from it at all, just set theDiscoveryRequestFunc
andReponseForRequestFunc
properties on create. A significant advantage of using this approach is ability to make use of state and variables available to the calling code in your request/response code. Of course, you can split the service definition between host and clients - setDiscoveryRequestFunc
in the client, andResponseForRequestFunc
in the host.
Too Abstract Ryan! Not Enough Code!
Fair enough - I said the goal was to make this simple, then spent all this time talking through the internals. For any process, the high level flow is the same. Assuming serviceDef
is an instance of IServiceDefinition
:
Host:
// set up publisher and start listening
var publisher = serviceDef.CreateServicePublisher();
publisher.Publish();
Clients:
// set up discoverer and response handler
var discoverer = serviceDef.CreateServiceDiscoverer();
discover.DiscoveredServices.Subscribe(svc => /* handle your responses */);
// start sending discovery requests
discoverer.StartDiscovering();
Now some service definition examples.
A basic, non-conditional response service
// responds to all requests with its ip/port as a string
var serviceDef = new FuncyJsonServiceDefinition<string, string>()
{
DiscoveryRequestFunc = () => "EHLO",
ResponseForRequestFunc = _ => String.Format("{0}:{1}", myIP, myPort)
};
A service that doesn't like green
(I know there's no Color
class in most PCL profiles, but hey look NGraphics!)
Assuming details
is a string with the ip/port combination:
// ignores requests from users whose favourite colour is green
var serviceDef = new FuncyJsonServiceDefinition<Color, string>()
{
DiscoveryRequestFunc = () => this.FavouriteColour,
ResponseForRequestFunc = color => color != Color.Green ? details : null
};
If ResponseFor
returns null, no response is sent - it's as if there is no service. A real-world use for this might be requiring host and players to have a shared password before allowing them to interact.
A service sending back a complex response object
Imagine a multiplayer 'lobby' where the user can see a list of open games, their details and current number of players.
public class GameSessionSummary
{
public string SessionName { get; set; }
public string GameName { get; set; }
public string GameCategory { get; set; }
public int GameMinPlayers { get; set; }
public int GameMaxPlayers { get; set; }
public SessionState State { get; set; }
public string Address { get; set; }
public int Port { get; set; }
}
// send back the details for any open games waiting for players
List<GameSessionSummary> _gameSessions; // managed elsewhere
var serviceDef = new FuncyJsonServiceDefinition<string, List<GameSessionSummary>>()
{
DiscoveryRequestFunc = () => _selectedGameCategory,
ResponseForRequestFunc = cat => _gameSessions
.Where(s=> s.State == SessionState.InLobby)
.Where(s=> s.GameCategory == cat)
.ToList()
};
Because the ServiceDiscoverer
is polling at some frequent interval (e.g. each second) and the ResponseForRequestFunc
is executed for each request, the information returned to the player will continue to reflect joining/leaving players and any other changes to game sesssions.
Ok, that is simple! Where's the code?
Check it out at the github repo.
If you have any thoughts on any of this or how the approach could be improved, let me know at the repo!
Today's reference is to the ReactiveUI sample - "A color chooser than doesn't like green". Another 500 points for that one.