diff --git a/Directory.Packages.props b/Directory.Packages.props index 5da5d20..485bec1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,6 +11,7 @@ + diff --git a/DrawTogether.sln b/DrawTogether.sln index 93503e0..cd15684 100644 --- a/DrawTogether.sln +++ b/DrawTogether.sln @@ -27,6 +27,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DrawTogether.Entities", "sr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DrawTogether.Actors", "src\DrawTogether.Actors\DrawTogether.Actors.csproj", "{5863DD74-00DE-4DE9-9F0A-FCBC32195E78}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DrawTogether.Tests", "src\DrawTogether.Tests\DrawTogether.Tests.csproj", "{78CAFA4F-0DBC-45F7-8794-48785FDFDD47}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -109,6 +111,18 @@ Global {5863DD74-00DE-4DE9-9F0A-FCBC32195E78}.Release|x64.Build.0 = Release|Any CPU {5863DD74-00DE-4DE9-9F0A-FCBC32195E78}.Release|x86.ActiveCfg = Release|Any CPU {5863DD74-00DE-4DE9-9F0A-FCBC32195E78}.Release|x86.Build.0 = Release|Any CPU + {78CAFA4F-0DBC-45F7-8794-48785FDFDD47}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {78CAFA4F-0DBC-45F7-8794-48785FDFDD47}.Debug|Any CPU.Build.0 = Debug|Any CPU + {78CAFA4F-0DBC-45F7-8794-48785FDFDD47}.Debug|x64.ActiveCfg = Debug|Any CPU + {78CAFA4F-0DBC-45F7-8794-48785FDFDD47}.Debug|x64.Build.0 = Debug|Any CPU + {78CAFA4F-0DBC-45F7-8794-48785FDFDD47}.Debug|x86.ActiveCfg = Debug|Any CPU + {78CAFA4F-0DBC-45F7-8794-48785FDFDD47}.Debug|x86.Build.0 = Debug|Any CPU + {78CAFA4F-0DBC-45F7-8794-48785FDFDD47}.Release|Any CPU.ActiveCfg = Release|Any CPU + {78CAFA4F-0DBC-45F7-8794-48785FDFDD47}.Release|Any CPU.Build.0 = Release|Any CPU + {78CAFA4F-0DBC-45F7-8794-48785FDFDD47}.Release|x64.ActiveCfg = Release|Any CPU + {78CAFA4F-0DBC-45F7-8794-48785FDFDD47}.Release|x64.Build.0 = Release|Any CPU + {78CAFA4F-0DBC-45F7-8794-48785FDFDD47}.Release|x86.ActiveCfg = Release|Any CPU + {78CAFA4F-0DBC-45F7-8794-48785FDFDD47}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/DrawTogether.Actors/DrawTogether.Actors.csproj b/src/DrawTogether.Actors/DrawTogether.Actors.csproj index 8896dcb..54ebfa9 100644 --- a/src/DrawTogether.Actors/DrawTogether.Actors.csproj +++ b/src/DrawTogether.Actors/DrawTogether.Actors.csproj @@ -9,10 +9,18 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/src/DrawTogether.Actors/Serialization/DrawingProtocolSerializer.cs b/src/DrawTogether.Actors/Serialization/DrawingProtocolSerializer.cs new file mode 100644 index 0000000..db2b1dd --- /dev/null +++ b/src/DrawTogether.Actors/Serialization/DrawingProtocolSerializer.cs @@ -0,0 +1,491 @@ +using System.Collections.Immutable; +using Akka.Actor; +using Akka.Hosting; +using Akka.Serialization; +using DrawTogether.Actors.Serialization.Proto; +using DrawTogether.Entities; +using DrawTogether.Entities.Drawings; +using DrawTogether.Entities.Drawings.Messages; +using DrawTogether.Entities.Users; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using ConnectedStroke = DrawTogether.Entities.Drawings.ConnectedStroke; +using DrawingSessionState = DrawTogether.Entities.Drawings.DrawingSessionState; +using Point = DrawTogether.Entities.Drawings.Point; +using Type = System.Type; + +namespace DrawTogether.Actors.Serialization; + +public static class CustomSerializationAkkaExtensions +{ + public static AkkaConfigurationBuilder AddDrawingProtocolSerializer(this AkkaConfigurationBuilder builder) + { + return builder.WithCustomSerializer("drawing", new[] { typeof(IWithDrawingSessionId) }, + system => new DrawingProtocolSerializer(system)); + } +} + +public sealed class DrawingProtocolSerializer : SerializerWithStringManifest +{ + // generate manifests for all classes that implement the IWithDrawingSessionId interface - keep them to 2 or 3 characters in length + private const string StrokeAddedManifest = "sa"; + private const string StrokeRemovedManifest = "sr"; + private const string StrokesClearedManifest = "sc"; + private const string UserAddedManifest = "ua"; + private const string UserRemovedManifest = "ur"; + private const string DrawingSessionClosedManifest = "dc"; + private const string GetDrawingSessionStateManifest = "gs"; + private const string SubscribeToDrawingSessionManifest = "su"; + private const string SubscribeAcknowledgedManifest = "sak"; + private const string UnsubscribeFromDrawingSessionManifest = "uu"; + private const string UnsubscribeAcknowledgedManifest = "uak"; + private const string AddStrokeManifest = "as"; + private const string RemoveStrokeManifest = "rs"; + private const string ClearStrokesManifest = "cs"; + private const string AddUserManifest = "au"; + private const string RemoveUserManifest = "ru"; + private const string DrawingActivityUpdateManifest = "da"; + private const string DrawingSessionStateManifest = "ds"; + + // arbitrary id but not within 0-100, which is reserved by Akka.NET + public override int Identifier => 481; + + public DrawingProtocolSerializer(ExtendedActorSystem system) : base(system) + { + } + + public override byte[] ToBinary(object obj) + { + switch (obj) + { + case IDrawingSessionEvent e: + return e switch + { + DrawingSessionEvents.StrokeAdded sa => ToProto(sa).ToByteArray(), + DrawingSessionEvents.StrokeRemoved sr => ToProto(sr).ToByteArray(), + DrawingSessionEvents.StrokesCleared sc => ToProto(sc).ToByteArray(), + DrawingSessionEvents.UserAdded ua => ToProto(ua).ToByteArray(), + DrawingSessionEvents.UserRemoved ur => ToProto(ur).ToByteArray(), + DrawingSessionEvents.DrawingSessionClosed dc => ToProto(dc).ToByteArray(), + _ => throw new ArgumentException($"Can't serialize object of type {e.GetType()}") + }; + case IDrawingSessionCommand c: + return c switch + { + DrawingSessionCommands.AddStroke asCmd => ToProto(asCmd).ToByteArray(), + DrawingSessionCommands.RemoveStroke rsCmd => ToProto(rsCmd).ToByteArray(), + DrawingSessionCommands.ClearStrokes csCmd => ToProto(csCmd).ToByteArray(), + DrawingSessionCommands.AddUser auCmd => ToProto(auCmd).ToByteArray(), + DrawingSessionCommands.RemoveUser ruCmd => ToProto(ruCmd).ToByteArray(), + _ => throw new ArgumentException($"Can't serialize object of type {c.GetType()}") + }; + case IDrawingSessionQuery q: + return q switch + { + DrawingSessionQueries.GetDrawingSessionState gs => ToProto(gs).ToByteArray(), + DrawingSessionQueries.SubscribeToDrawingSession su => ToProto(su).ToByteArray(), + DrawingSessionQueries.SubscribeAcknowledged sak => ToProto(sak).ToByteArray(), + DrawingSessionQueries.UnsubscribeFromDrawingSession uu => ToProto(uu).ToByteArray(), + DrawingSessionQueries.UnsubscribeAcknowledged uak => ToProto(uak).ToByteArray(), + _ => throw new ArgumentException($"Can't serialize object of type {q.GetType()}") + }; + case DrawingActivityUpdate da: + return ToProto(da).ToByteArray(); + case DrawingSessionState state: + return ToProto(state).ToByteArray(); + default: + throw new ArgumentException($"Can't serialize object of type {obj.GetType()}"); + } + } + + private Proto.DrawingActivityUpdated ToProto(DrawingActivityUpdate asCmd) + { + var protoDrawingActivityUpdated = new Proto.DrawingActivityUpdated() + { + DrawingSessionId = asCmd.DrawingSessionId.SessionId, + LastUpdated = asCmd.LastUpdate.ToTimestamp(), + ActiveUsers = asCmd.ActiveUsers, + IsRemoved = asCmd.IsRemoved + }; + return protoDrawingActivityUpdated; + } + + private Proto.SubscribeToDrawingSessionState ToProto(DrawingSessionQueries.SubscribeToDrawingSession asCmd) + { + var protoSubscribeToDrawingSessionState = new Proto.SubscribeToDrawingSessionState() + { + DrawingSessionId = asCmd.DrawingSessionId.SessionId + }; + return protoSubscribeToDrawingSessionState; + } + + private Proto.SubscribeAcknowledged ToProto(DrawingSessionQueries.SubscribeAcknowledged asCmd) + { + var protoSubscribeAcknowledged = new Proto.SubscribeAcknowledged() + { + DrawingSessionId = asCmd.DrawingSessionId.SessionId + }; + return protoSubscribeAcknowledged; + } + + private Proto.UnsubscribeFromDrawingSessionState ToProto(DrawingSessionQueries.UnsubscribeFromDrawingSession asCmd) + { + var protoUnsubscribeFromDrawingSessionState = new Proto.UnsubscribeFromDrawingSessionState() + { + DrawingSessionId = asCmd.DrawingSessionId.SessionId + }; + return protoUnsubscribeFromDrawingSessionState; + } + + private Proto.UnsubscribeAcknowledged ToProto(DrawingSessionQueries.UnsubscribeAcknowledged asCmd) + { + var protoUnsubscribeAcknowledged = new Proto.UnsubscribeAcknowledged() + { + DrawingSessionId = asCmd.DrawingSessionId.SessionId + }; + return protoUnsubscribeAcknowledged; + } + + + private Proto.AddStroke ToProto(DrawingSessionCommands.AddStroke asCmd) + { + var protoAddStroke = new Proto.AddStroke() + { + DrawingSessionId = asCmd.DrawingSessionId.SessionId, + ConnectedStroke = ToProto(asCmd.Stroke) + }; + return protoAddStroke; + } + + private Proto.RemoveStroke ToProto(DrawingSessionCommands.RemoveStroke rsCmd) + { + var protoRemoveStroke = new Proto.RemoveStroke() + { + DrawingSessionId = rsCmd.DrawingSessionId.SessionId, + StrokeId = rsCmd.StrokeId.Id + }; + return protoRemoveStroke; + } + + private Proto.ClearStrokes ToProto(DrawingSessionCommands.ClearStrokes csCmd) + { + var protoClearStrokes = new Proto.ClearStrokes() + { + DrawingSessionId = csCmd.DrawingSessionId.SessionId + }; + return protoClearStrokes; + } + + private Proto.AddUser ToProto(DrawingSessionCommands.AddUser auCmd) + { + var protoAddUser = new Proto.AddUser() + { + DrawingSessionId = auCmd.DrawingSessionId.SessionId, + UserId = auCmd.UserId.IdentityName + }; + return protoAddUser; + } + + private Proto.RemoveUser ToProto(DrawingSessionCommands.RemoveUser ruCmd) + { + var protoRemoveUser = new Proto.RemoveUser() + { + DrawingSessionId = ruCmd.DrawingSessionId.SessionId, + UserId = ruCmd.UserId.IdentityName + }; + return protoRemoveUser; + } + + private Proto.GetDrawingSessionState ToProto(DrawingSessionQueries.GetDrawingSessionState gs) + { + var protoGetDrawingSessionState = new Proto.GetDrawingSessionState() + { + DrawingSessionId = gs.DrawingSessionId.SessionId + }; + return protoGetDrawingSessionState; + } + + + private Proto.AddStroke ToProto(DrawingSessionEvents.StrokeAdded stroke) + { + var protoStrokeAdded = new Proto.AddStroke() + { + DrawingSessionId = stroke.DrawingSessionId.SessionId, + ConnectedStroke = ToProto(stroke.Stroke) + }; + return protoStrokeAdded; + } + + private Proto.RemoveStroke ToProto(DrawingSessionEvents.StrokeRemoved stroke) + { + var protoStrokeRemoved = new Proto.RemoveStroke() + { + DrawingSessionId = stroke.DrawingSessionId.SessionId, + StrokeId = stroke.StrokeId.Id + }; + return protoStrokeRemoved; + } + + private Proto.ClearStrokes ToProto(DrawingSessionEvents.StrokesCleared strokes) + { + var protoStrokesCleared = new Proto.ClearStrokes() + { + DrawingSessionId = strokes.DrawingSessionId.SessionId + }; + return protoStrokesCleared; + } + + private Proto.AddUser ToProto(DrawingSessionEvents.UserAdded user) + { + var protoUserAdded = new Proto.AddUser() + { + DrawingSessionId = user.DrawingSessionId.SessionId, + UserId = user.UserId.IdentityName + }; + return protoUserAdded; + } + + private Proto.RemoveUser ToProto(DrawingSessionEvents.UserRemoved user) + { + var protoUserRemoved = new Proto.RemoveUser() + { + DrawingSessionId = user.DrawingSessionId.SessionId, + UserId = user.UserId.IdentityName + }; + return protoUserRemoved; + } + + private Proto.SessionClosed ToProto(DrawingSessionEvents.DrawingSessionClosed closed) + { + var protoDrawingSessionClosed = new Proto.SessionClosed() + { + DrawingSessionId = closed.DrawingSessionId.SessionId + }; + return protoDrawingSessionClosed; + } + + public override object FromBinary(byte[] bytes, string manifest) + { + return manifest switch + { + StrokeAddedManifest => FromProtoStrokeAdded(Proto.AddStroke.Parser.ParseFrom(bytes)), + StrokeRemovedManifest => FromProtoStrokeRemoved(Proto.RemoveStroke.Parser.ParseFrom(bytes)), + StrokesClearedManifest => FromProtoStrokesCleared(Proto.ClearStrokes.Parser.ParseFrom(bytes)), + UserAddedManifest => FromProtoUserAdded(Proto.AddUser.Parser.ParseFrom(bytes)), + UserRemovedManifest => FromProtoUserRemoved(Proto.RemoveUser.Parser.ParseFrom(bytes)), + DrawingSessionClosedManifest => FromProtoSessionClosed(Proto.SessionClosed.Parser.ParseFrom(bytes)), + DrawingActivityUpdateManifest => FromProto(Proto.DrawingActivityUpdated.Parser.ParseFrom(bytes)), + GetDrawingSessionStateManifest => FromProto(Proto.GetDrawingSessionState.Parser.ParseFrom(bytes)), + SubscribeToDrawingSessionManifest => + FromProto(Proto.SubscribeToDrawingSessionState.Parser.ParseFrom(bytes)), + SubscribeAcknowledgedManifest => FromProto(Proto.SubscribeAcknowledged.Parser.ParseFrom(bytes)), + UnsubscribeFromDrawingSessionManifest => FromProto( + Proto.UnsubscribeFromDrawingSessionState.Parser.ParseFrom(bytes)), + UnsubscribeAcknowledgedManifest => FromProto(Proto.UnsubscribeAcknowledged.Parser.ParseFrom(bytes)), + AddStrokeManifest => FromProto(Proto.AddStroke.Parser.ParseFrom(bytes)), + RemoveStrokeManifest => FromProto(Proto.RemoveStroke.Parser.ParseFrom(bytes)), + ClearStrokesManifest => FromProto(Proto.ClearStrokes.Parser.ParseFrom(bytes)), + AddUserManifest => FromProto(Proto.AddUser.Parser.ParseFrom(bytes)), + RemoveUserManifest => FromProto(Proto.RemoveUser.Parser.ParseFrom(bytes)), + DrawingSessionStateManifest => FromProto(Proto.DrawingSessionState.Parser.ParseFrom(bytes)), + _ => throw new ArgumentException($"Can't deserialize object with manifest {manifest}") + }; + } + + private DrawingSessionCommands.RemoveUser FromProto(RemoveUser protoStroke) + { + return new DrawingSessionCommands.RemoveUser(new DrawingSessionId(protoStroke.DrawingSessionId), + new UserId(protoStroke.UserId)); + } + + private DrawingSessionCommands.AddUser FromProto(AddUser protoStroke) + { + return new DrawingSessionCommands.AddUser(new DrawingSessionId(protoStroke.DrawingSessionId), + new UserId(protoStroke.UserId)); + } + + private DrawingSessionCommands.ClearStrokes FromProto(ClearStrokes protoStroke) + { + return new DrawingSessionCommands.ClearStrokes(new DrawingSessionId(protoStroke.DrawingSessionId)); + } + + private DrawingSessionCommands.RemoveStroke FromProto(RemoveStroke protoStroke) + { + return new DrawingSessionCommands.RemoveStroke(new DrawingSessionId(protoStroke.DrawingSessionId), + new StrokeId(protoStroke.StrokeId)); + } + + private DrawingSessionCommands.AddStroke FromProto(AddStroke protoStroke) + { + return new DrawingSessionCommands.AddStroke(new DrawingSessionId(protoStroke.DrawingSessionId), + FromProto(protoStroke.ConnectedStroke)); + } + + private DrawingSessionQueries.UnsubscribeAcknowledged FromProto(UnsubscribeAcknowledged protoStroke) + { + return new DrawingSessionQueries.UnsubscribeAcknowledged(new DrawingSessionId(protoStroke.DrawingSessionId)); + } + + private DrawingSessionQueries.UnsubscribeFromDrawingSession FromProto( + UnsubscribeFromDrawingSessionState protoStroke) + { + return new DrawingSessionQueries.UnsubscribeFromDrawingSession( + new DrawingSessionId(protoStroke.DrawingSessionId)); + } + + private DrawingSessionQueries.SubscribeAcknowledged FromProto(SubscribeAcknowledged protoStroke) + { + return new DrawingSessionQueries.SubscribeAcknowledged(new DrawingSessionId(protoStroke.DrawingSessionId)); + } + + private DrawingSessionQueries.SubscribeToDrawingSession FromProto(SubscribeToDrawingSessionState protoStroke) + { + return new DrawingSessionQueries.SubscribeToDrawingSession(new DrawingSessionId(protoStroke.DrawingSessionId)); + } + + private DrawingSessionQueries.GetDrawingSessionState FromProto(GetDrawingSessionState protoStroke) + { + return new DrawingSessionQueries.GetDrawingSessionState(new DrawingSessionId(protoStroke.DrawingSessionId)); + } + + private DrawingActivityUpdate FromProto(DrawingActivityUpdated protoStroke) + { + return new DrawingActivityUpdate(new DrawingSessionId(protoStroke.DrawingSessionId), protoStroke.ActiveUsers, + protoStroke.LastUpdated.ToDateTime(), protoStroke.IsRemoved); + } + + private DrawingSessionEvents.DrawingSessionClosed FromProtoSessionClosed(SessionClosed parseFrom) + { + return new DrawingSessionEvents.DrawingSessionClosed(new DrawingSessionId(parseFrom.DrawingSessionId)); + } + + private DrawingSessionEvents.UserRemoved FromProtoUserRemoved(RemoveUser parseFrom) + { + return new DrawingSessionEvents.UserRemoved(new DrawingSessionId(parseFrom.DrawingSessionId), + new UserId(parseFrom.UserId)); + } + + private DrawingSessionEvents.UserAdded FromProtoUserAdded(AddUser parseFrom) + { + return new DrawingSessionEvents.UserAdded(new DrawingSessionId(parseFrom.DrawingSessionId), + new UserId(parseFrom.UserId)); + } + + private DrawingSessionEvents.StrokesCleared FromProtoStrokesCleared(ClearStrokes parseFrom) + { + return new DrawingSessionEvents.StrokesCleared(new DrawingSessionId(parseFrom.DrawingSessionId)); + } + + private DrawingSessionEvents.StrokeRemoved FromProtoStrokeRemoved(RemoveStroke parseFrom) + { + return new DrawingSessionEvents.StrokeRemoved(new DrawingSessionId(parseFrom.DrawingSessionId), + new StrokeId(parseFrom.StrokeId)); + } + + private DrawingSessionEvents.StrokeAdded FromProtoStrokeAdded(AddStroke parseFrom) + { + return new DrawingSessionEvents.StrokeAdded(new DrawingSessionId(parseFrom.DrawingSessionId), + FromProto(parseFrom.ConnectedStroke)); + } + + public override string Manifest(object o) + { + // return the constant string value for the manifest + return o switch + { + IDrawingSessionEvent => GetManifestForEvent(o), + IDrawingSessionCommand => GetManifestForCommand(o), + IDrawingSessionQuery => GetManifestForQuery(o), + DrawingActivityUpdate => DrawingActivityUpdateManifest, + DrawingSessionState => DrawingSessionStateManifest, + _ => throw new ArgumentException($"Can't serialize object of type {o.GetType()}") + }; + } + + private static string GetManifestForQuery(object o) + { + return o switch + { + DrawingSessionQueries.GetDrawingSessionState => GetDrawingSessionStateManifest, + DrawingSessionQueries.SubscribeToDrawingSession => SubscribeToDrawingSessionManifest, + DrawingSessionQueries.SubscribeAcknowledged => SubscribeAcknowledgedManifest, + DrawingSessionQueries.UnsubscribeFromDrawingSession => UnsubscribeFromDrawingSessionManifest, + DrawingSessionQueries.UnsubscribeAcknowledged => UnsubscribeAcknowledgedManifest, + _ => throw new ArgumentException($"Can't serialize object of type {o.GetType()}") + }; + } + + private static string GetManifestForCommand(object o) + { + return o switch + { + DrawingSessionCommands.AddStroke => AddStrokeManifest, + DrawingSessionCommands.RemoveStroke => RemoveStrokeManifest, + DrawingSessionCommands.ClearStrokes => ClearStrokesManifest, + DrawingSessionCommands.AddUser => AddUserManifest, + DrawingSessionCommands.RemoveUser => RemoveUserManifest, + _ => throw new ArgumentException($"Can't serialize object of type {o.GetType()}") + }; + } + + private static string GetManifestForEvent(object o) + { + return o switch + { + DrawingSessionEvents.StrokeAdded => StrokeAddedManifest, + DrawingSessionEvents.StrokeRemoved => StrokeRemovedManifest, + DrawingSessionEvents.StrokesCleared => StrokesClearedManifest, + DrawingSessionEvents.UserAdded => UserAddedManifest, + DrawingSessionEvents.UserRemoved => UserRemovedManifest, + DrawingSessionEvents.DrawingSessionClosed => DrawingSessionClosedManifest, + DrawingActivityUpdate => DrawingActivityUpdateManifest, + _ => throw new ArgumentException($"Can't serialize object of type {o.GetType()}") + }; + } + + Proto.ConnectedStroke ToProto(ConnectedStroke stroke) + { + var protoStroke = new Proto.ConnectedStroke + { + Id = stroke.Id.Id, + Points = { stroke.Points.Select(p => new Proto.Point { X = p.X, Y = p.Y }) }, + StrokeWidth = stroke.StrokeWidth.Value, + StrokeColor = stroke.StrokeColor.HexCodeOrColorName + }; + return protoStroke; + } + + ConnectedStroke FromProto(Proto.ConnectedStroke protoStroke) + { + var stroke = new ConnectedStroke(new StrokeId(protoStroke.Id)) + { + Points = protoStroke.Points.Select(p => new Point(p.X, p.Y)).ToList(), + StrokeWidth = new GreaterThanZeroInteger(protoStroke.StrokeWidth), + StrokeColor = new Color(protoStroke.StrokeColor) + }; + return stroke; + } + + Proto.DrawingSessionState ToProto(DrawingSessionState state) + { + var protoState = new Proto.DrawingSessionState + { + DrawingSessionId = state.DrawingSessionId.SessionId, + ConnectedStrokes = { state.Strokes.Select(v => ToProto(v.Value)) }, + ConnectedUsers = { state.ConnectedUsers.Select(u => u.IdentityName) }, + LastUpdated = state.LastUpdate.ToTimestamp() + }; + return protoState; + } + + DrawingSessionState FromProto(Proto.DrawingSessionState protoState) + { + var state = new DrawingSessionState(new DrawingSessionId(protoState.DrawingSessionId)) + { + Strokes = protoState.ConnectedStrokes.ToImmutableDictionary(s => new StrokeId(s.Id), s => FromProto(s)), + ConnectedUsers = protoState.ConnectedUsers.Select(u => new UserId(u)).ToImmutableHashSet(), + LastUpdate = protoState.LastUpdated.ToDateTime() + }; + return state; + } +} \ No newline at end of file diff --git a/src/DrawTogether.Actors/Serialization/Proto/DrawingProtocol.proto b/src/DrawTogether.Actors/Serialization/Proto/DrawingProtocol.proto new file mode 100644 index 0000000..2cfd974 --- /dev/null +++ b/src/DrawTogether.Actors/Serialization/Proto/DrawingProtocol.proto @@ -0,0 +1,86 @@ +syntax = "proto3"; + +package DrawTogether.Actors.Serialization.Proto; + +import "google/protobuf/timestamp.proto"; + +/* Primitives */ +message Point { + double x = 1; + double y = 2; +} + +message ConnectedStroke { + int32 id = 1; + repeated Point points = 2; + int32 strokeWidth = 3; + string strokeColor = 4; +} + +/* Commands and events */ +message AddUser { + string drawingSessionId = 1; + string userId = 2; +} + +message RemoveUser { + string drawingSessionId = 1; + string userId = 2; + +} + +message AddStroke { + string drawingSessionId = 2; + ConnectedStroke connectedStroke = 3; +} + +message RemoveStroke { + string drawingSessionId = 1; + int32 strokeId = 2; +} + +message ClearStrokes { + string drawingSessionId = 1; +} + +message SessionClosed { + string drawingSessionId = 1; +} + +/* State */ + +message DrawingSessionState{ + string drawingSessionId = 1; + repeated ConnectedStroke connectedStrokes = 2; + repeated string connectedUsers = 3; + google.protobuf.Timestamp lastUpdated = 4; +} + +/* Queries */ + +message GetDrawingSessionState { + string drawingSessionId = 1; +} + +message SubscribeToDrawingSessionState { + string drawingSessionId = 1; +} + +message SubscribeAcknowledged { + string drawingSessionId = 1; +} + +message UnsubscribeFromDrawingSessionState { + string drawingSessionId = 1; +} + +message UnsubscribeAcknowledged { + string drawingSessionId = 1; +} + +message DrawingActivityUpdated { + string drawingSessionId = 1; + google.protobuf.Timestamp lastUpdated = 2; + int32 activeUsers = 3; + bool isRemoved = 4; +} \ No newline at end of file diff --git a/src/DrawTogether.Entities/Drawings/ConnectedStroke.cs b/src/DrawTogether.Entities/Drawings/ConnectedStroke.cs index e062a51..03432b2 100644 --- a/src/DrawTogether.Entities/Drawings/ConnectedStroke.cs +++ b/src/DrawTogether.Entities/Drawings/ConnectedStroke.cs @@ -40,4 +40,16 @@ public sealed record ConnectedStroke(StrokeId Id) public GreaterThanZeroInteger StrokeWidth { get; init; } = GreaterThanZeroInteger.Default; public Color StrokeColor { get; init; } = Color.Black; + + public bool Equals(ConnectedStroke? other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + return StrokeWidth.Equals(other.StrokeWidth) && StrokeColor.Equals(other.StrokeColor) && Id.Equals(other.Id) && Points.SequenceEqual(other.Points); + } + + public override int GetHashCode() + { + return HashCode.Combine(Points, StrokeWidth, StrokeColor, Id); + } } \ No newline at end of file diff --git a/src/DrawTogether.Entities/Drawings/DrawingSessionState.cs b/src/DrawTogether.Entities/Drawings/DrawingSessionState.cs index ef249c7..eecdebf 100644 --- a/src/DrawTogether.Entities/Drawings/DrawingSessionState.cs +++ b/src/DrawTogether.Entities/Drawings/DrawingSessionState.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.Text; using DrawTogether.Entities.Drawings.Messages; using DrawTogether.Entities.Users; using static DrawTogether.Entities.Drawings.Messages.DrawingSessionCommands; @@ -13,8 +14,31 @@ public sealed record DrawingSessionState(DrawingSessionId DrawingSessionId) : IW public ImmutableHashSet ConnectedUsers { get; init; } = ImmutableHashSet.Empty; public DateTime LastUpdate { get; init; } = DateTime.UtcNow; - + public bool IsEmpty => Strokes.IsEmpty && ConnectedUsers.IsEmpty; + + public bool Equals(DrawingSessionState? other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + return LastUpdate.Equals(other.LastUpdate) && DrawingSessionId.Equals(other.DrawingSessionId) + && Strokes.Values.SequenceEqual(other.Strokes.Values) && + ConnectedUsers.SetEquals(other.ConnectedUsers); + } + + public override int GetHashCode() + { + return HashCode.Combine(Strokes, ConnectedUsers, LastUpdate, DrawingSessionId); + } + + private bool PrintMembers(StringBuilder builder) + { + builder.Append(nameof(DrawingSessionId)).Append(" = ").Append(DrawingSessionId) + .Append(", ").Append(nameof(Strokes)).Append(" = ").AppendFormat("Count = {0}", Strokes.Count) + .Append(", ").Append(nameof(ConnectedUsers)).Append(" = ").AppendFormat("Count = {0}", ConnectedUsers.Count) + .Append(", ").Append(nameof(LastUpdate)).Append(" = ").Append(LastUpdate); + return true; + } } public static class DrawingSessionStateExtensions @@ -96,13 +120,17 @@ public static (CommandResult commandResult, IDrawingSessionEvent[] events) Proce } } } - + public static DrawingSessionState Apply(this DrawingSessionState currentState, IDrawingSessionEvent @event) { var e = @event switch { DrawingSessionEvents.StrokeAdded strokeAdded => - currentState with { Strokes = currentState.Strokes.SetItem(strokeAdded.Stroke.Id, strokeAdded.Stroke), LastUpdate = DateTime.UtcNow}, + currentState with + { + Strokes = currentState.Strokes.SetItem(strokeAdded.Stroke.Id, strokeAdded.Stroke), + LastUpdate = DateTime.UtcNow + }, DrawingSessionEvents.StrokeRemoved strokeRemoved => currentState with { Strokes = currentState.Strokes.Remove(strokeRemoved.StrokeId) }, DrawingSessionEvents.StrokesCleared => @@ -113,7 +141,7 @@ public static DrawingSessionState Apply(this DrawingSessionState currentState, I currentState with { ConnectedUsers = currentState.ConnectedUsers.Remove(userRemoved.UserId) }, _ => currentState }; - + e = e with { LastUpdate = DateTime.UtcNow }; return e; } diff --git a/src/DrawTogether.Entities/Drawings/Messages/DrawingSessionEvents.cs b/src/DrawTogether.Entities/Drawings/Messages/DrawingSessionEvents.cs index 0debaa7..60930ce 100644 --- a/src/DrawTogether.Entities/Drawings/Messages/DrawingSessionEvents.cs +++ b/src/DrawTogether.Entities/Drawings/Messages/DrawingSessionEvents.cs @@ -6,8 +6,6 @@ public interface IDrawingSessionEvent : IWithDrawingSessionId { } public static class DrawingSessionEvents { - public sealed record DrawingSessionCreated(DrawingSessionId DrawingSessionId) : IDrawingSessionEvent; - public sealed record StrokeAdded(DrawingSessionId DrawingSessionId, ConnectedStroke Stroke) : IDrawingSessionEvent; public sealed record StrokeRemoved(DrawingSessionId DrawingSessionId, StrokeId StrokeId) : IDrawingSessionEvent; diff --git a/src/DrawTogether.Entities/Drawings/Messages/DrawingSessionQueries.cs b/src/DrawTogether.Entities/Drawings/Messages/DrawingSessionQueries.cs index 2016a1e..aa512d5 100644 --- a/src/DrawTogether.Entities/Drawings/Messages/DrawingSessionQueries.cs +++ b/src/DrawTogether.Entities/Drawings/Messages/DrawingSessionQueries.cs @@ -6,8 +6,6 @@ public static class DrawingSessionQueries { public sealed record GetDrawingSessionState(DrawingSessionId DrawingSessionId) : IDrawingSessionQuery; - public sealed record GetDrawingSessionUsers(DrawingSessionId DrawingSessionId) : IDrawingSessionQuery; - public sealed record SubscribeToDrawingSession(DrawingSessionId DrawingSessionId) : IDrawingSessionQuery; public sealed record SubscribeAcknowledged(DrawingSessionId DrawingSessionId) : IDrawingSessionQuery; diff --git a/src/DrawTogether.Entities/Drawings/Messages/IWithDrawingSessionId.cs b/src/DrawTogether.Entities/Drawings/Messages/IWithDrawingSessionId.cs index 67d4163..f8e54a5 100644 --- a/src/DrawTogether.Entities/Drawings/Messages/IWithDrawingSessionId.cs +++ b/src/DrawTogether.Entities/Drawings/Messages/IWithDrawingSessionId.cs @@ -1,6 +1,4 @@ -using DrawTogether.Entities.Users; - -namespace DrawTogether.Entities.Drawings.Messages; +namespace DrawTogether.Entities.Drawings.Messages; /// /// Marker interface for messages that have a . diff --git a/src/DrawTogether.Tests/DrawTogether.Tests.csproj b/src/DrawTogether.Tests/DrawTogether.Tests.csproj new file mode 100644 index 0000000..0d475e2 --- /dev/null +++ b/src/DrawTogether.Tests/DrawTogether.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/src/DrawTogether.Tests/Serialization/DrawingProtocolSerializerSpecs.cs b/src/DrawTogether.Tests/Serialization/DrawingProtocolSerializerSpecs.cs new file mode 100644 index 0000000..6b514f4 --- /dev/null +++ b/src/DrawTogether.Tests/Serialization/DrawingProtocolSerializerSpecs.cs @@ -0,0 +1,62 @@ +using System.Collections.Immutable; +using Akka.Hosting; +using Akka.Hosting.TestKit; +using Akka.Serialization; +using DrawTogether.Actors.Serialization; +using DrawTogether.Entities; +using DrawTogether.Entities.Drawings; +using DrawTogether.Entities.Users; +using FluentAssertions; + +namespace DrawTogether.Tests.Serialization; + +public class DrawingProtocolSerializerSpecs : TestKit +{ + // generate a test case for serializing each type that implements IWithDrawingSessionId + [Fact] + public void ShouldSerializeDrawingSessionState() + { + var drawingSessionState = new DrawingSessionState(new DrawingSessionId("iD1")) + { + ConnectedUsers = ImmutableHashSet.Empty.Add(new UserId("user1")).Add(new UserId("user2")), + Strokes = ImmutableDictionary.Empty.Add(new StrokeId(1), + new ConnectedStroke(new StrokeId(1)) + { + StrokeWidth = new GreaterThanZeroInteger(4), StrokeColor = new Color("white"), Points = new[] + { + new Point(2, 3), + new Point(2, 2) + } + }) + }; + + VerifySerialization(drawingSessionState); + } + + [Fact] + public void ShouldSerializeDrawingActivityUpdate() + { + var drawingActivityUpdate1 = new DrawingActivityUpdate(new DrawingSessionId("foo"), 4, DateTime.UtcNow, true); + var drawingActivityUpdate2 = new DrawingActivityUpdate(new DrawingSessionId("foo"), 0, DateTime.UtcNow, false); + + VerifySerialization(drawingActivityUpdate1); + VerifySerialization(drawingActivityUpdate2); + } + + private void VerifySerialization(TMessage message) + { + var serializerFor = (SerializerWithStringManifest)Sys.Serialization.FindSerializerFor(message); + serializerFor.Should().BeOfType(); + + var manifest = serializerFor.Manifest(message); + var bytes = serializerFor.ToBinary(message); + + var deserialized = (TMessage)serializerFor.FromBinary(bytes, manifest); + deserialized.Should().BeEquivalentTo(message); + } + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + builder.AddDrawingProtocolSerializer(); + } +} \ No newline at end of file diff --git a/src/DrawTogether/Config/AkkaConfiguration.cs b/src/DrawTogether/Config/AkkaConfiguration.cs index ae75773..c798717 100644 --- a/src/DrawTogether/Config/AkkaConfiguration.cs +++ b/src/DrawTogether/Config/AkkaConfiguration.cs @@ -6,6 +6,7 @@ using DrawTogether.Actors; using DrawTogether.Actors.Drawings; using DrawTogether.Actors.Local; +using DrawTogether.Actors.Serialization; using LinqToDB; namespace DrawTogether.Config; @@ -29,6 +30,7 @@ public static IServiceCollection ConfigureAkka(this IServiceCollection services, { builder.WithRemoting(akkaSettings.RemoteOptions) .WithClustering(akkaSettings.ClusterOptions) + .AddDrawingProtocolSerializer() .WithSqlPersistence( connectionString: connectionString, providerName: ProviderName.SqlServer2022,