Elenesski Object Database  EODB and NEODB
The Memento Pattern Database for Unity
35 Network - Sample Code

Basic Code

This section provides sample code for the NEODB datbase.

Constructing a NEODB instance

In order to work with the network, you will need to identify some basic information:

public class Example : MonoBehaviour {

    private NEODB _TheNetwork;

    public void Awake() {
        _TheNetwork = new NEODB(this, "https://myhost.com/neo_sample");
    }
}

The NEODB class requires 2 parameters:

  • A MonoBehaviour instance. It uses the coroutines to start background processes to call the PHP server and wait for result.
  • The URL of your PHP server. Remember to implement a secured host with SSL/HTTPS.
    • SSL/HTTPS is overkill while developing, but once you release, it's essential.

Making Calls

Every server call you make needs to have at least two callback methods defined. One to handle the success of your call and the other to handle any failures, it might look like this:

_TheNetwork.MyServerCall(Success,Failure);

The methods might be defined as:

private void Success() {}
private void Failure(Exception aException) {}

Failure() works by receiving an exception and you can then decide if you should throw it or how to handle the error. Success() is called when it works properly, which should be all time, except when the connection to the Internet is severed, there is an issue with the ISP or some other issue.

The actual format of the Failure method is the same in call cases, accepting an Exception as a parameter, however the Success method will be different in most cases.

Getting Data into an object

Getting data is the same as EODB does it. You call a descriptor with:

new EODBDescriptor(OID, OBJECT);

Where OID is the object identifier you want to load and OBJECT is the object where attribute values are loaded into.

Very Important!! The data ONLY comes from the local in-memory EODB database. To get an object into the EODB from the networked database, you must find it first. This is why there are so many find methods. These are several find methods available to you, see the section on finding below for more information. When you save an Object, the object is saved within to Local as well.

The major difference is the addition of Success and Failure parameters on the EODBDescriptor. Here is a constructor which loads the object:

public ExampleObject(NEODB aDatabase, int aOID, Action aSuccess, Action<Exception> aFailure) {
    Database = aDatabase;
    _Success = aSuccess;
    _Failure = aFailure;
    new EODBDescriptor(aOID, this);
}

The _Success and _Failure methods are only used during a save, therefore, you will need to remember what they are so that the DBSave() method can be coded properly.

You will notice that Database in the constructor is a NEODB, but in the IEODBClass interface it is:

EODB Database { get; }

This works because NEODB is a subclass of EODB.

Saving Data

Like the getting data counterpart, saving data in the networked database is very similar too, with only the addition of the two extra success and failure methods.

public void DBSave() {
    new EODBDescriptor(this,-99,0,_Success,_Failure);
}

Save EODBDescriptor's must have Success and Failure methods

If you do not include Success and Failure methods when constructing a new EODBDescriptor, you will get one of two exceptions:

if ( aDescriptor.Success == null )
    throw new NEODBGeneralException("SUCCESS method in the descriptor cannot be null.");

if ( aDescriptor.Failure == null )
    throw new NEODBGeneralException("FAILURE method in the descriptor cannot be null.");

Defining your Object's Structure

Defining the structure of your object is identical. The exact same encoding process that EODB uses is used by NEODB:

public void Definition(EODBDescriptor aDescriptor) {
    aDescriptor.Structure(()=>Field1,A=>Field1=A);
    aDescriptor.Structure(()=>Field2,A=>Field2=A);

    aDescriptor.Index("CONCAT",Field1 + ":" + Field2);
}

For details on defining an object's structure, see Class Definitions

Finding Data

NEODB offers more more than a dozen different ways to find data on indexes offer a variety of options; see next section. Once data is found, you can get it into your application using a EODBDescriptor call in an object's descriptor.

  • ServerFindOID - Accepts one or a list of OIDs to find (overloaded).
  • ServerFindClass - Finds all objects equal to a given class name
  • ServerFindAll - Finds all objects in a given index.
  • ServerFindEquals - Finds objects in the index with the same value as what is provided.
  • ServerFindNotEquals - Finds objects in the index not equal to the value.
  • ServerFindLessThan - Finds objects in the index less than the value.
  • ServerFindLessThanOrEqual - Finds objects in the index less than or eqal to the value.
  • ServerFindGreaterThan - Finds objects greater in the index than the value.
  • ServerFindGreaterThanOrEqual - Finds objects in the index greater than or equal to the value.
  • ServerFindBetween - Uses two value parameters to find objects in the index in a range and then uses these values to find objects.
    • For example, "between 5 and 10", will find all values greater than or equal to 5 and all values less than or equal to 10.
  • ServerFindNotBetween - Finds objects in the index that fall outside of the range defined by two parameters.
  • ServerFindLike - Finds objects in STRING ONLY indexes who's values matches a wild card search.
    • Use "%" to match many characters and "_" to match one character.
    • If you need to find a "%", "_" or "\" character you specify, respectively, "\%", "\_" or "\\".
  • ServerFindNotLike - Finds objects in the STRING ONLY index that doesn't match a wild card string.

Finding Data in an Index

Most of the find methods have a set of calling parameters that look like this one:

public void ServerFindLessThanEqual(
    string             aIndexIdentifier,
    NEODBSort          aSorting, 
    string             aValue,
    Action<List<int>>  aSuccess, 
    Action<Exception>  aFailure, 
    int                aStart = -1, 
    int                aLimit = -1, 
    Action<int,string> aSpecialIndexProcessor = null) {

The parameters for this call are described below:

INDEX IDENTIFIER

This is the name of the INDEX as defined on the server. If the Index name doesn't exist, the system returns with a failure. It's strongly recommended that create "const" for all your index names to make sure you get the spelling right.

SORTING

This is your NEODBSort sorting options and can be None, Ascending or Descending. Note, you will get faster database performance if you use None when you don't need sorting.

VALUE

Value is the what you are testing with and how it is used will depend on the type of call you are making. In this case, the we are saying find all values in the index less than or equal to this value. While Value is passed as a string into the function, it's important to know the type of index you are working with since it is possible to have different types. For example, passing "XYZ" to an index defined as an integer will cause a failure.

SUCCESS

This is your success method when it is finally called. This method in this object is defined:

Action<List<int>> aSuccess

This accepts a method that accepts a list of integers, such as:

private void FindLTESuccess( List<int> aOIDS ) {...}

By default you simply get a list of object identifiers. The object values have been loaded into the memory database EODB, and to get the values, you must construct an object using EODBDescriptor to get them loaded into an object.

FAILURE

This is the method that is called with an exception to identify the nature of the problem your call did not succeeed at.

START and LIMIT

Start and Limit is used to define pages of data and identifies the starting point (in terms of row number) in the result set to find. Values begin at "1" (one) to represent row "1" (one) of the result. If you want all the data you find returned, leave both of these values at -1.

Specify START without limit, gives you all rows starting at the row number provided by start. Leaving START as -1 ignores the parameter.

For example, if your pages are 10 rows each:

  • To get the first page
    • START = 1
    • LIMIT = 10
  • To get 6th page:
    • START = 61
    • LIMIT = 10

SPECIAL INDEX PROCESSOR

The special index processor is a method that allows you to process the values off the index directly without retrieving the underlying objects too. The method accepts an OID integer and string value which is index value from NEODB, converted to a string.

The processor is called for each row that is successfully returned from NEODB after a call. When all the rows are processed, SUCCESS is called with a list of the OIDs. Unlike a method call without this parameter, the underlying objects found will not be stored within EODB.

How you use the indexed values is up to you. They might be part of a simplified display mechanisms as discussed in the section "Understanding Latency" or some other processing. If, after you process the list, you want to get one or more of the objects, you can use this method to get the objects:

.ServerFindOID( List<int> aOIDs , ... );

Understanding and Working with Object Locks

Locks in NEODB are exclusive and semi-permanent. Locks only affect the ability to change an object; not read it. Once a lock is assigned to an object, that lock id must be specified before an object can be updated, deleted or have it's lock cleared. Once a lock is cleared, anybody can update or delete an object without the lock or assign a new lock in it's place.

In a pre-release version of NEODB lock ids were numeric, but in the 1.x version, lock ids are case sensitive string fields up to 25 characters in length. It's important to think carefully how you are going to lock your objects, because program bugs and data losses can result in objects that can only be unlocked by editing the database directly. Lock IDs can be thought of as being similar to a password; except that they are stored as clear text in the database.

Setting a Lock - Syntax

ServerLockSet(int aOID, string aLockID, Action aSuccess, Action<Exception> aFailure );

Clearing a Lock - Syntax

ServerLockClear(int aOID, string aLockID, Action aSuccess, Action<Exception> aFailure);

Identifying the Lock ID

Setting and clearing a lock on a specific OID is a server call, but to make sure the correct lock is in play when you perform an update, there is an property in your NEODB database that you must set:

NEODB TheDB = new NEODB(...);

// in an IEODBClass implemented class, 
TheDB.DatabaseLockID = "My password";

You will get an exception if you assign a null to the ID or assign a string field longer than 25 characters. If you want to clear the DatabaseLockID, use:

ClearDatabaseLockID();

Simplifying Network Classes with NEODBClass

NEODBClass is an abstract class that implements IEODBClass and provides a standard initialization mechanism that can greatly simply the specification of a class that will be stored in NEODB. It abstracts away all of the repetitive networking code allows you to define a class like this:

public class PokerPlayer : NEODBClass {

    public string PlayerName { get; set; }
    public string PlayerPassword { get; set; }
    public int Cash { get; set; }
    public byte[] AvatarJpeg { get; set; }

    // Construct a new player
    public PokerPlayer(NEODB aDatabase, 
                            string aUserName , 
                            string aPlayerPassword, 
                            int aCash , 
                            Action aSaveSuccess , 
                            Action<Exception> aFailure, 
                            NewOIDType aGetNewOID ) 
                                : base(aDatabase,aSaveSuccess,aFailure,aGetNewOID) {

        UserName = aUserName;
        PlayerPassword = aPlayerPassword;
        Cash = aCash;

        AvatarJpeg = new Texture2D(128, 128).EncodeToPNG();
    }

    // Load an existing player
    public PokerPlayer(int aOID , 
                            NEODB aDatabase, 
                            ClassLoadType aLoadType, 
                            Action<int?,NEODBClass> aLoadSuccess, 
                            Action aSaveSuccess , 
                            Action<Exception> aFailure) 
                                : base(aOID,aDatabase,aLoadType,aLoadSuccess,aSaveSuccess,aFailure) {
    }
    public override string ClassName() {
        return "PLAYER";
    }

    public override void Definition(EODBDescriptor aDescriptor) {
        aDescriptor.Structure(() => PlayerName, A => PlayerName = A);
        aDescriptor.Structure(() => PlayerPassword, A => PlayerPassword = A);
        aDescriptor.Structure(() => Cash, A => Cash = A);
        aDescriptor.Structure(() => AvatarJpeg, A => AvatarJpeg = A);
    }

}

NewOIDType is an enum that identifies how to assign an OID when creating an object. You have 4 options.

  • STATIC means the OID is less than 0 (throws an exception if it's not).
    • Functionally, STATIC and EXTERNAL (below) don't assign an OID, but STATIC checks to make sure the assigned OID is less than zero, as per convention.
  • GENERATE means the constructor will call the server immediately to get an OID; if aGenerateNotification is defined, you'll get a notification of what the OID was when it's assigned along with the NEODBClass it was assigned to.
  • ONSAVE assigns a special number to the OID which is replaced by a new OID when the server does a save.
    • The OID only becomes know until after a save is successful.
    • This is useful when you don't need to know the OID prior to a save, and can save an extra round trip to the server to get an OID you ultimately don't care about.
    • It's used by NEODBEventManager to save events.
  • EXTERNAL means that the OID is assigned by your logic after the object is constructed.
    • Usually you grab a block of OIDs and assign them to objects as required.

You'll notice that DBSave() is not described. Instead, it uses a default definition:

public virtual void DBSave() {
    new EODBDescriptor(this, OID, Version, _SaveSuccess, _Failure);
}

Sequencing Network Calls with NEODBChains

Overview and Sample

Since your code is dealing with asynchronous calls to the network, it can become somewhat arduous to put together a series of calls. For example, consider these steps where you want to insert two new rows into the database. The steps are:

  • Get two new OIDs.
  • Assign the OID1 to a new object1 and save object 1.
  • Assign the OID2 to a new object2, assign a reference to object1 and then save object 2.

Making these calls in code is certainly possible, but with NEODB you can use a utility class called NEODBChain to simplify this process greatly. You use NEODBChain in a class to provide the harness for your sequence of method calls. In this example, which implements the example above, we get OID1 and OID2, store them within the class, then use them later create the two objects on the SERVER:

public class ChainExample {

    private readonly NEODB _Database;
    private NEODBChain _Chain;

    private int _OID1;
    private int _OID2;

    public ChainExample(NEODB aDatabase, Action aFinished ) {
        _Database = aDatabase;

        _Chain = new NEODBChain();
        _Chain.AddStep(GetOIDs);
        _Chain.AddStep(CreateAndSaveObject1);
        _Chain.AddStep(CreateAndSaveObject2);

        _Chain.AddStep(aFinished);
    }

    // Since ServerGetNewOID is expecting a Action<int> (instead of the Action) with the chain, we
    // create an anonymous delegate to deal with the parameter, then start the next step.  Simply getting
    // the next step won't start it.
    public void GetOIDs() {
        _Database.ServerGetNewOID(delegate(int aFIRST_OID) {
            _OID1 = aFIRST_OID;
            _OID2 = aFIRST_OID + 1;
            _Chain.Next();
        },_Chain.GetExceptionHandler(),2);  // The "2" means we are getting two OIDs

    }

    public void CreateAndSaveObject1() {

        ExampleObject EO1 = new ExampleObject(_Database, _Chain.Next, _Chain.GetExceptionHandler());
        EO1.OID = _OID1;
        EO1.Field1 = 111111;
        EO1.DBSave();

        // We don't need to call StartNext() because the success method is GetNext() 
        // which automatically gets the next method and starts it.

    }

    public void CreateAndSaveObject2() {

        ExampleObject2 EO2 = new ExampleObject2(_Database, _Chain.Next, _Chain.GetExceptionHandler());
        EO2.OID = _OID2;
        EO2.Object1Reference = _OID1;
        EO2.OtherField = 222222;
        EO2.DBSave();

        // We don't need to call StartNext() because the success method is GetNext() 
        // which automatically gets the next method and starts it.


    }

    public void StartChain() {
        _Chain.StartChain();
    }
}

Hierarchical Chains

The entire point of a chain is to call NEODB in a way that mimics a synchronous call but doesn't stall your main thread. So one step executes, then waits until it's done before calling the next step.

If you use the Chain feature, you'll discover several uses for the capability. There are times, especially during initialization, where you want a chain to use other chains. For example, consider:

NEODBChain SUB1 = new NEODBChain();
NEODBChain SUB1A = new NEODBChain();
NEODBChain SUB1B = new NEODBChain();
NEODBChain SUB2 = new NEODBChain();
NEODBChain SUB3 = new NEODBChain();

NEOBDChain MAIN = new NEODBChain();

Where you want to mimic this kind of calling hierarchy:

  • MAIN
    • SUB1
      • SUB1A
      • SUB1B
    • SUB2
    • SUB3

You can use the NEODBChain hierarchical chain capability, which removes a bunch of complexity or recoding when you want a main chain to use subchains. To set this up, you code:

SUB1.AddStep( SUB1A );
SUB1.AddStep( SUB1B );

MAIN.AddStep( SUB1 );
MAIN.AddStep( SUB2 );
MAIN.AddStep( SUB3 );

MAIN.Start();

When MAIN starts, it sees the SUB1 chain and this causes SUB1 to start executing, MAIN temporarily paused. Similarly, SUB1 is temporarily paused and SUB1A starts to execute. When SUB1A is finished, SUB1 starts again. When it's finished, MAIN is allowed to continue.

Important: Chain hierarchies are dynamic. This means that if you start executing SUB1 again a some point, later in your game, it doesn't restart MAIN or get to SUB2. This strategy allows subchains to be used depending on conditions, and mimics much more closely how methods are used in regular C# coding. For example,

NEOBDChain LATER = new NEODBChain();

LATER.AddStep( SUB1 ) 
LATER.AddStep( SUB3 );

LATER.Start();

Which causes SUB1, SUB1A, SUB1B, then SUB3 to execute.

Step Conditionals

Conditional give you the ability to define whether a step or chain (see below) should start. It's a function you assign when you add the step and returns a true/false result. Conditionals work on actions and sub-chains:

_StartNewGame = new NEODBChain(ReportFailure);

_StartNewGame.AddStep(() => myObject.Hits = myObject.ScanIndex() );
_StartNewGame.AddStep(() => myObject.ReportProgress() ,() => myObject.FoundData() );

Will only execute ReportProgress if the method myObject.FoundData() returns TRUE.

Synchronous Steps

A synchronous step is a method that gets called and returns immediately without the need for you to code the Chain.Next() call. It's useful for building logic that occurs after a server call returns. For example:

_StartNewGame = new NEODBChain(ReportFailure);
_StartNewGame.AddStep(() => _NEODB.ServerPurgeDynamicIndex(_PokerGame.DynamicIndexName, _StartNewGame.Next, ReportFailure));
_StartNewGame.AddStep(() => _PokerSendMsg.ToPlayer_WaitingForPlayers(() => _StartNewGame.Next()));
_StartNewGame.AddStep( AddHostPlayerToGame , null , true );
_StartNewGame.AddStep(() => _PokerSendMsg.ToPlayer_JoinTableResponse(_PokerPlayer.OID, true, 0 , _StartNewGame.Next));

_StartNewGame.StartChain();

"AddHostPlayerToGame" is a method that is called after waiting for players is successfully sent. It's called immediately then ToPlayer_JoinTableResponse(..) is called. There is no need to code a Next() as it's done automatically.

Action Step Replacements

In rare cases it is possible to replace steps with new steps and keep the original chain in place. For example, consider this code:

_StartNewGame = new NEODBChain(ReportFailure);
_StartNewGame.AddStep(() => _PokerSendMsg.ToPlayer_WaitingForPlayers(() => _StartNewGame.Next()));
_AddStep = _StartNewGame.AddStep( AddHostPlayerToGame , null , true );

Then later on in your code you say:

_StartNewGame.ReplaceStep( _AddStep , delegate { SomethingElse(); } );

And SomethingElse() starts instead of AddHostPlayerToGame().

Value Chains

A value chain is a special generic NEODBChain subclass which allows you to store a value/object with a chain. The generic can be any type, such as a dictionary, or any primitive, structure or class. This simple extension can help reduce or even eliminate the need to create a new class to offer a similar capability.

Construction of the value chain is very easy:

NEODBChainValue<int> myChain = new NEODBChainValue( 5 );

Constructs a new Chain Value instance storing "5" in an integer field called "Value". You can use this value, or redefine it with StartChain method:

myChain.StartChain( 12 );

Replaces the original value "5" with a new value "12" that is used throughout the chain's execution.

Implementing Event Handlers with NEODBEventManager

Overview

Imagine a game where you want to send a lot of different messages between piers, such as, the online poker game included with NEODB.

NEODBEventManager allows you to send and receive messages. It is a single class and, based on a unique integer message type, dynamically encodes or decodes which parameters are included in that message. You create method calls inside of your derived class that create messages, then provide a handler (that usually implements an Interface) to receive the messages. The result is the ability to call C# methods to send messages and to receive messages through called C# methods.

Besides sending messages via your derived class, the only thing you need to do is make a periodic query to NEODB using NEODBEventManager.FindMessages() ... for each message it finds, it calls your handler. Game logic decides which methods are implemented. If you want to ignore the message, simply create a method that performs no function.

Getting Started

You start by implementing a class that inherits from NEODBEventManager. This class implements IEODBClass with a bunch of additional features. The base implementation doesn't look complex, and this class will compile, but of course, you cannot send or receive messages with it.

public class TestClass : NEODBEventManager {

    public TestClass(NEODB aNEODB, string aIndexName, Action<Exception> aFailure) 
              : base(aNEODB, aIndexName, aFailure) {
    }

    public override string ClassName() {
        return "EVENT";
    }

}

The power in the event manager is through it's interface which involves accepting some object attributes, then saving them so that when a custom definition method is called, they are loaded/saved from these attributes into a custom definition call. For example:

public void ToHost_AnteResponse( int aPlayerOID , int aAnte , int aPlayerCash ) {
    PlayerOID = aPlayerOID;
    Ante = aAnte;
    PlayerCash = aPlayerCash;

    Send( (int)MessageTypes.PlayerAnte );
}

In the object's constructor, you call methods similar to this one. I like to keep both methods together in the code so that the right parameters are being set, saved and retrieved.

private void PlayerAnteDefinition() {
    Add((int)MessageTypes.PlayerAnte,                       
        delegate(EODBDescriptor aDescriptor) {
            aDescriptor.Structure(() => PlayerOID, DATA => PlayerOID = DATA);
            aDescriptor.Structure(() => Ante, DATA => Ante = DATA);
            aDescriptor.Structure(() => PlayerCash, DATA => PlayerCash = DATA);
    }, 

    () => _Handler.AnteResponse(PlayerOID, Ante, PlayerCash));
}

The Add method is simple. It simply initializes two dictionaries based on a unique message ID:

  • The first accepts an EODBDescriptor object, and this defines the custom Definition() for a message.
  • The second identifies an action. The purpose of the action is to call a message handler with the parameters in the message it receives.

When "Send(...)" executes, it initializes the object with the unique ID, then looks up which Definition to then call. The resulting object is then stored on the remote index. Periodically your game calls "FindMessages()" which is included in NEODBEventManager. This method scans the index looking for new messages. When it finds one, it decodes the object, identifying the correct Definition method, then calls an action which then sends it to your handler.

Since your game may have many messages being exchanged, how do you decide who receives which messages? The simple answer is all piers in your game receive all the messages and it's your game logic that decides which messages are received or ignored. In the Online Poker game, there is a general host/player relationship, but there can be up to 5 players. So while the host can send messages to players, some messages are directed toward specific players.

Therefore each pier implements the full interface, and logic dictates what to look at and what to ignore.

public class PokerMessageHandlerHost : IPokerMessages {

    public void JoinTableRequest(int aPlayerOID) {
        ... Implemented by the Host
    }

    public void JoinTableResponse(int aPlayerOID, bool aGranted, int aPlayerTablePosition) {
        ... Implemented by the Player, and ignored by the Host
    }

    ...

}

By adopting this strategy, the code never has to include an "IF" statement that says something like "if I am the host, implement this handler". Instead, you implement all handlers and simply choose to ignore a message when it is sent. This results in much cleaner code.

Consider this message:

// Sent by the PLAYER to the HOST to respond to a betting request.
void PlayerActionResponse( int aPlayerOID 
                            , PokerMessageManager.PlayerAction aAction 
                            , int aActionAmount 
                            , int aPlayerCash );

Which is triggered with:

_PokerSendMsg.ToHost_ActionResponse(4235,PokerMessageManager.PlayerAction.Raise, 250, 14515);

While it is clear this message is destined for the host to do something, other players could see this message and update that player with their current cash amount and a message like "Raises $250" so that the host doesn't have to send a message to each player with a text update message.

As a result, it makes sense to allow all piers to listen to the same messages, and let logic dictate how to handle them.