WebSocket #
Example of using WebSocket with BLoC pattern.
Project #
The project is based on an example project provided by my colleague Alex. You can find his original here.
I’ve reimplemented the frontend in Flutter using BLoC to manage state changes based on events send from the backend.
Flutter frontend #
Code is found in flutter_frontend
.
dart_mappable is used to enhance
model classes through code generation. It helps create immutable classes,
combining features from equatable and
json_serializable with a
copyWith
method added.
Code generation can be run with:
dart run build_runner build
Getting started #
If you have docker then you can start a database by running sh setup.sh
.
Otherwise, adjust PG_CONN
in Api/appsettings.Development.json
.
Start backend:
dotnet watch --project Api
Start Flutter frontend:
cd flutter_frontend
flutter pub get
flutter run -d chrome
Start Angular frontend:
cd frontend
npm install
npm start
Emulator #
To connect to the websocket running on your own machine from Android emulator,
you will need to change the address to 10.0.2.2
.
That is because the emulator is running a full OS, therefore localhost
inside the emulator is different from localhost on you host OS.
See Set up Android Emulator networking.
How it works #
Websocket #
The web_socket_channel package is used to connect to the backend.
You connect to a WebSocket with the WebSocketChannel class. It provides an interface that resembles a StreamController. Messages added to the sink will be sent to the connected server. Messages sent from the server can be observed from the stream. A message here is just a String.
Read more on how to Communicate with WebSockets.
The WebSocket protocol for the chat app is based on JSON events.
Each event has a eventType
.
Events send from client start with "ClientWants"
Events from server starts with "Server"
.
All events are defined in flutter_frontend/lib/models/events.dart
.
When sending events to the server we need the serialized events to have
eventType
.
When deserializing events from server, the eventType
is used to determine
which class to user.
We can achieve this by adding a discriminatorKey
to a shared base class for
all events.
@MappableClass(discriminatorKey: 'eventType')
abstract class BaseEvent with BaseEventMappable {}
Each event type is a subclass with a discriminatorValue
.
@MappableClass(discriminatorValue: ClientWantsToSignIn.name)
class ClientWantsToSignIn extends BaseEvent with ClientWantsToSignInMappable {
static const String name = "ClientWantsToSignIn";
// ...
}
It allows the generated mapper to be able to deserialize to the correct
subclass based on the value of eventType
.
If we have the following:
final event = BaseEventMapper.fromJson('{"eventType": "ClientWantsToSignIn"}');
Then event
will have the runtime type ClientWantsToSignIn
.
BLoC #
The protocol and state changes are implemented in
flutter_frontend/lib/bloc/chat_bloc.dart
.
Bloc was chosen over Cubit. Because we are dealing with events.
See Cubit vs. Bloc.
Client events #
ChatBloc exposes methods to add events based on user interactions. Here is an example:
/// Sends ClientWantsToSignIn event to server
void signIn({required String password, required String email}) {
add(ClientWantsToSignIn(
eventType: ClientWantsToSignIn.name,
email: email,
password: password,
));
}
Adding events triggers the handler for the corresponding event type.
on<ClientWantsToSignIn>(_onClientEvent);
When the BLoC receives ClientWantsToSignIn
event then _onClientEvent
will
be invoked to handle the event.
The handler method serializes events to JSON, before they are sent to the server. Sending to server is done by adding messages to the channels sink.
FutureOr<void> _onClientEvent(ClientEvent event, Emitter<ChatState> emit) {
_channel.sink.add(event.toJson());
}
Server events #
The constructor listens to messages from server.
It deserializes messages to the correct subclass based on eventType
.
Then trigger the corresponding event handler, by passing the event to add
.
// Feed deserialized events from server into this bloc
_channelSubscription = _channel.stream
.map((event) => ServerEvent.fromJson(event))
.listen(add, onError: addError);
Each event is handled by an event handler.
// Handlers for server events
on<ServerAddsClientToRoom>(_onServerAddsClientToRoom);
on<ServerAuthenticatesUser>(_onServerAuthenticatesUser);
on<ServerBroadcastsMessageToClientsInRoom>(
_onServerBroadcastsMessageToClientsInRoom);
on<ServerNotifiesClientsInRoomSomeoneHasJoinedRoom>(
_onServerNotifiesClientsInRoomSomeoneHasJoinedRoom);
on<ServerSendsErrorMessageToClient>(_onServerSendsErrorMessageToClient);
Event handlers emit a new state. This new state is copy of previous state with new information added from the event. Here is an example for when client has authenticated:
FutureOr<void> _onServerAuthenticatesUser(
ServerAuthenticatesUser event, Emitter<ChatState> emit) {
_jwt = event.jwt;
emit(state.copyWith(
authenticated: true,
headsUp: 'Authentication successful!',
));
}
Note: The JWT is in ChatState because it is a secret value that shouldn’t be shown in UI.
Models #
dart_mappable is used to enhance the model classes.
Here is an example:
// This file is "model.dart"
import 'package:dart_mappable/dart_mappable.dart';
// Will be generated by dart_mappable
part 'model.mapper.dart';
@MappableClass()
class MyClass with MyClassMappable {
final int myValue;
MyClass(this.myValue);
}
When using dart_mappable, make sure you have
part 'model.mapper.dart'
,@MappableClass()
andwith MyClassMappable
in your code. The code generation won’t work correctly without it and you will get errors that can be difficult to figure out.It needs to follow the naming of the code you are writing. So if your file is named
x.dart
then you needpart 'x.mapper.dart
. Likewise, if your class is namedX
then you needwith XMappable
.Note
XMappable
won’t exist before you have executed the code generation. You can run code generation with:dart run build_runner build