Websocket realtime chat application. https://chat.zoloto.cx.ua/ https://chat.zoloto.cx.ua/info
git clone https://github.com/Denis-Source/socket_chat.git
After cloning the repository, go to the backend
folder, configure the virtual environment and install dependencies.
cd .\socket_chat\backend\
python -m venv env
.\env\Scripts\Activate.ps1
pip install -r .\requirements.txt
You can specify the port and IP address in the configs.
IP = "127.0.0.1"
PORT = 9000
There is an option to specify the storage solution. In the example below, the mock memory storage is used.
from storage.memory_storage import MemoryStorage
STORAGE_CLS = MemoryStorage
After the initial configuration, you will be able to run the backend part of the application.
python .\main.py
2022-09-12 18:08:33,964 DEBUG asyncio Using proactor: IocpProactor
2022-09-12 18:08:33,965 INFO server starting server
2022-09-12 18:08:33,966 INFO websockets.server server listening on 127.0.0.1:9000
The frontend configuration is more straight forward.
cd ..\frontend\
npm i
npm start
In a nutshell, it is a simple one-page application with a minimalistic design and a Two-tab layout:
The mobile version is also available but it lacks the functionality if compared to the desktop one:
All of the changes, incoming messages and other things can be viewed in a realtime, as the application is based on websocket communication protocol.
Both frontend and backend provide verbose logging. The frontend one is visible by a user in the left tab and displays all of the communications between him and the server:
The application does not require registration of any sort as the user name is generated at the start:
The application is completely anonymous and does not store any user data as it is not needed. Also, there is no form of roles or admin privileges, anyone can do anything.
Most of the names are changeable, the username as well.
All entities of the application are distinguished by their universally unique identifier (UUID), so all of the names initially have that 00000000-0000-0000-0000-000000000000
look.
The main objective of the application is to provide an ability to exchange messages between people, which takes place in rooms. The room can be created or deleted by anyone and it is fully customizable:
The room list is sorted by the room creation time, to solve the problem of navigating a potential overwhelmingly large number of them, the search is provided.
After the room is selected, the user is provided with an unlimited message history and an ability to create their own messages: The amount of rooms and messages are NOT LIMITED.
When entered the room, there is still an option to see rooms, by switching the left tab to the room list: Both left and right tabs can be selected. The one consists of logging and room list, the right one โ a list of messages, a list of users entered the room and a drawing.
As a bonus, every room has a tab with a simple drawing board:
There is a simple selection of tools and colors that can be used. The drawing is shared between all of the room attendants.
Having a simple design allows the application to easily change its looks with a theme slider:
There are 8 different themes available with the following color schemes:
The backend of the applications is written using WebSockets library. As it is a relatively simple demo, the application was written from scratch. The data travels through the internal structure:
It is parsed with the Utils
class, then goes to the Server
class which decides what to do with it. All of the actions are done with the Model
classes which are internally mapped with a database via the Storage
class.
All of the communications of the applications are done in the following format:
{
"type": "result",
"payload": {
"message": "user_created",
"object": {
"uuid": "02687b26-8620-4936-9675-00c2a06f43c8",
"name": "user-02687b26-8620-4936-9675-00c2a06f43c8",
"room_uuid": null
}
}
}
This statement comes first from the server and tells the client it's identity.
The statement has one of the following types:
result
that comes from the server and tells a client what to do;call
that comes from the client and tells the server what to do;error
.
All of the statement have payload
field.
message
specifies the type of actions that were or should be performed. Both the backend and frontend side have enumerations that list all of the available message
types.
To send a data, payload
could have other fields, such as object
, list
.
The statement used to change a room color:
{
"type": "call",
"payload": {
"message": "change_color",
"uuid": "45843334-b2ea-4673-86d1-6c8aab920b74",
"color": "#ff0000"
}
}
All of the incoming messages are filtered and cleaned with the Utils
class parse_statement
method with the specified validators.
The prepare_statement
method is on the other hand is used to construct a statement based on type, message and other additional parameters.
The main logic is done in Server
class that based on the incoming messages. To avoid messiness, all of the callable methods are grouped in the dictionary:
self.call_methods = {
RoomCallStatements.CREATE_ROOM: self.create_room,
RoomCallStatements.DELETE_ROOM: self.delete_room,
RoomCallStatements.LIST_ROOMS: self.list_rooms,
RoomCallStatements.ENTER_ROOM: self.enter_room,
RoomCallStatements.LEAVE_ROOM: self.leave_room,
RoomCallStatements.CHANGE_ROOM_COLOR: self.change_room_color,
RoomCallStatements.CHANGE_ROOM_NAME: self.change_room_name,
UserCallStatements.CHANGE_USER_NAME: self.change_user_name,
MessageCallStatements.CREATE_MESSAGE: self.create_message,
MessageCallStatements.LIST_MESSAGES: self.list_messages,
DrawingCallStatements.CHANGE_DRAW_LINE: self.change_draw_line,
DrawingCallStatements.GET_DRAWING: self.get_drawing,
DrawingCallStatements.RESET_DRAWING: self.reset_drawing
}
To handle the connection with a user, there is also self.connections
dictionary, that maps user uuid with the corresponding websocket connection.
The websocket is based on a generator, so the for loop is used, to handle user
statements:
async for raw_message in self.connections.get(user.uuid):
method_type, payload = Utils.parse_statement(raw_message)
method = self.call_methods[method_type]
await method(user, payload)
In a case of calling the non existing method or not providing needed data, server sends the corresponding error statement.
To divide the server logic and direct data manipulation, the Model
classes are implemented.
As we can see from the AbstractModel
all of the classes should implement the following class methods:
create
to create a model instancelist
to list all of the model instancesget
to get a specific model instance (based onuuid
)delete
to delete a model instance
And instance get_dict
to construct a dictionary representation of an instance that is easily JSON serializable.
The models uses Storage
class to save it's contents to the database. Since the websockets
library is asynchronous, the Storage
is made as well. That means that the model can not use default __init__()
constructor to create an instance, so the create()
class method is needed.
As said earlier, all of the Model
logic is mapped with the Storage
class, that is specified in the configs.
For example model class method get
simply gets the model from the storage:
@classmethod
async def get(cls, uuid: str):
cls.logger.debug(f"getting {cls.TYPE} ({uuid})")
return await cls.storage.get(cls.TYPE, uuid)
All of those methods are defined in the BaseModel
class and overriden if needed in the other children classes.
The BaseModel
also provides the uuid
generation which defines instance uniquness:
class BaseModel(AbstractModel):
def __init__(self):
self.uuid = str(uuid4())
self.name = f"{self.TYPE}-{self.uuid}"
All of the models should have a TYPE
that is defined in the ModelTypes
enumeration. This is crusial as it is used by the Storage
class.
class ModelTypes(str, Enum):
BASE = "base"
USER = "user"
ROOM = "room"
MESSAGE = "message"
DRAWING = "drawing"
LINE = "line"
COLOR = "color"
To separate mandate file or SQL operaions from the Model
class, the Storage
class was implemented. It's created using repository pattern, as it provides only 4 methods:
get()
;list()
;put()
;delete()
.
There are also 2 onetime-run static methods that are needed in some cases:
prepare()
;close()
.
All of those method are defined in the BaseStorage
class.
The BaseStorage
class implements those methods by using dictionary with the not yet implemented model type specific methods:
self._get_methods = {
ModelTypes.USER: self._get_user,
ModelTypes.ROOM: self._get_room,
ModelTypes.MESSAGE: self._get_message,
ModelTypes.DRAWING: self._get_drawing,
}
self._list_methods = {
ModelTypes.ROOM: self._list_rooms
}
self._put_methods = {
ModelTypes.USER: self._put_user,
ModelTypes.ROOM: self._put_room,
ModelTypes.MESSAGE: self._put_message,
ModelTypes.DRAWING: self._put_drawing,
ModelTypes.LINE: self._put_line,
}
self._delete_methods = {
ModelTypes.USER: self._delete_user,
ModelTypes.ROOM: self._delete_room,
}
So the child class should only implement those methods or the NotImplementedError
will be used.
For testing and developing purpouses the memory storage was created. It uses 5 different dictionaries to store models in memory:
class MemoryStorage(BaseStorage):
_users = {}
_rooms = {}
_messages = {}
_drawings = {}
_lines = {}
All of the methods are implemented by dictionary access or pop()
method.
To store data properly the AlchemyStorage
class was implemented that is based on the SQLAlchemy. The ORM models were used they are not the same models that were described previously. The ORM models provided by the library are then reconstructed in the appropriate Model
classes.
That decision unsures that the project does not depend on the SQLAlchemy storage solution.
To store data in the specified way, the model tables were created using declarative mapping (the imperative mappings did not work for me with the asynchronous extension).
User model table for example:
Base = declarative_base()
class UserDB(Base):
__tablename__ = "user"
__mapper_args__ = {'eager_defaults': True}
name = Column(String(64))
uuid = Column(String(36), primary_key=True)
room_uuid = Column(String, ForeignKey("room.uuid"))
room = relationship("RoomDB", back_populates="users")
messages = relationship("MessageDB", back_populates="user")
The primary key is
uuid
as it satisfies the uniqueness constraint. There are also relations with the other tables as should be.
All of the database manipulations are done with ORM Queries in 2.0 style using select
, update
and
delete
query factories that are used with the session. Results are converted to classes with scallars()
method.
Method to query a room for example:
async def _query_room(self, uuid: str) -> RoomDB:
query = select(RoomDB).options(selectinload(RoomDB.users), selectinload(RoomDB.drawing),
selectinload(RoomDB.messages)).filter_by(uuid=uuid)
result = await self._session.scalars(query)
return result.first()
Considering the nature of asynchronous sqlachemy extension, we should specify loading of the related fields by calling
options
with theselectinload
.
After the data is recieved from the DB, the Model
from_data()
methods come in handy.
Exapmle of the _get_user()
method:
async def _get_user(self, uuid: str) -> "models.user.User":
user_db = await self._query_user(uuid)
if user_db:
user = models.user.User.from_data(
user_db.name,
user_db.uuid,
user_db.room_uuid,
)
return user
else:
raise NotFoundException(uuid, ModelTypes.USER)
Given the ORM nature of SQLAlchemy, it is not that difficult to expand the previously mentioned AlchemyStorage
class to the one that has an ability to connect to various "real" databases. In fact the only difference is the declaration of the database engine
:
class PostgreStorage(AlchemyStorage):
_db_credentials = {
"user": "user_name",
"dbname": "chat_db",
"password": "passwd",
"address": "localhost"
}
NAME = "postgres_storage"
_engine = create_async_engine(
f"postgresql+asyncpg://"
f"{_db_credentials.get('user')}:"
f"{_db_credentials.get('password')}"
f"@{_db_credentials.get('address')}"
f"/{_db_credentials.get('dbname')}",
)
_session = AsyncSession(_engine, expire_on_commit=False)
Given the postgresql is installed, a database is created and the config.py
has STORAGE_CLS = PostgreStorage
, the backend will attempt to connect to the local postgreSQL server with the provided credentials.
Forntend is based on the React framework. The looks and feels were designed from scratch.
The project was written using TypeScript and follows the basic react project structure. All of the components are styled with sass.
All of the source files are located in src
folder. The application is split into components:
Buttons
that consists of the button template and all of the functional buttons, including navigation ones;ColorPicker
different custom color pickers for rooms and drawingDrawing
drawing component based on react-konvaStage
;Header
that displays the heading of the application and username;Input
components related to input fields;Log
components related to logging;Message
components related to messages;Room
components related to rooms;Spinner
;Tabs
components that group other components into a layout.
Considering that the project is written with TypeScript, there is Model
folder that provides interfaces for the Drawing
, Log
, Message
, Room
and User
interfaces.
Simple example of a user
interface:
export interface UserModel {
name: string;
room_uuid?: string;
uuid: string;
}
There are also Enumerations related to the statement types used to communicate with the server located in the StatementTypes
folder.
Taking into account that the project has a complex internal state (message history, drawing, log lists, etc.), the redux state manager was used. For every model, the corresponding redux slice was implemented.
The application calls the reducers with dispatch
to avoid infinite callback passing.
As mentioned previously.
The application relies on the websocket communication protocol. All of the communications were implemented using the library with the statement constructor prepareStatement()
in the api.ts
file.
To implement automatic scrolling animations on new messages, log items room etc.
The application has several lists that are potentially infinite in size. To avoid any lags related to it, this library was used to render only the viewed elements.
To have a drawing board
To have a theme switcher in the form of a slider.
To save a preferred theme in cookies, selected with a previously mentioned slider.
Only the theme number is stored in the cookies, there is no other data especially related to the user profile.
The application has sound notifications, so the appropriate library was used.
TypeScript does not recognize the
mp3
format with theES6
format, so the good oldrequire
was used for that.
Even though it is a one page application, it still uses sever routes such as homepage, room page, information page, etc.
Rooms have dynamic routing so it is possible to enter a room given its URL.