+12

Chat realtime sử dụng Nestjs + Socket.io và React + Redux-Saga

Chào mừng các bạn trở lại với series tutorial Nestjs của mình.

Đến hẹn lại lên như đã nói ở bài viết trước bài viết này mình lại cùng xây dựng React App Chat realtime : Nestjs + Socket.io, React + Redux-Saga nhé . Bắt đầu thôi

Index series

  1. Giới thiệu về setup repository + typeorm.
  2. Xác thực người dùng trong Nestjs sử dụng Passport JWT.
  3. Nestjs - Create relationship với Typeorm + mysql
  4. Tiếp tục series mình lại cùng xây dựng React App Chat realtime : Nestjs + Socket.io, React + Redux-Saga.

1. Cấu trúc

  • Cấu trúc sẽ bao gồm :
    • Server Side: Nestjs + socket.io
    • Client Side: ReactJs + socket-client + redux saga

2. Hướng xử lý

  • Server :
    • Tạo mới table "devices"
    • Khi client connect đến socket gateway: thực hiện lưu socket_id vào table "devices"
    • Khi client disconnect thực hiện xóa socket_id trong table "devices"
    • Khi có tin nhắn được emit thì tìm tất cả các socket_id của user trong conversation để gửi tin nhắn
  • Client: emit message và receive message

3. Server side

  1. Cài đặt các packet Ở đây thì mình vẫn sử dụng các pakage của các bài trước và sẽ cần thêm 1 số pakage khác nữa

    
     > npm install @nestjs/websockets
    
     > npm i socket.io
    
    
  2. Xử lý validation cho socket app.gateway.ts

    @WebSocketGateway(3006, { cors: true })
    export class AppGateway
      implements
      OnGatewayInit,
      OnGatewayConnection,
      OnGatewayDisconnect
    {
      @WebSocketServer() server: Server;
      private logger: Logger = new Logger('MessageGateway');
      constructor(
        private userService: UsersService,
        private jwtService: JwtService,
      ) {}
      
      //function get user from token
      async getDataUserFromToken(client: Socket): Promise<UserEntity> {
        const authToken: any = client.handshake?.query?.token;
        try {
          const decoded = this.jwtService.verify(authToken);
    
          return await this.userService.getUserByEmail(decoded.email); // response to function
        } catch (ex) {
          throw new HttpException('Not found', HttpStatus.NOT_FOUND);
        }
      }
    }
    
    
  3. Xử lý logic Client connect

    Vẫn là trong app.gateway.ts

    @WebSocketGateway(3006, { cors: true })
    export class AppGateway
      implements
      OnGatewayInit,
      OnGatewayConnection,
      OnGatewayDisconnect
    {
      @WebSocketServer() server: Server;
      private logger: Logger = new Logger('MessageGateway');
      constructor(
        private userService: UsersService,
        private deviceService: DeviceService,
        private jwtService: JwtService,
      ) {}
      
      ...
      
      async handleConnection(client: Socket) {
        this.logger.log(client.id, 'Connected..............................');
        const user: UserEntity = await this.getDataUserFromToken(client);
    
        const device = {
          user_id: user.id,
          type: TypeInformation.socket_id,
          status: false,
          value: client.id,
        };
    
        await this.deviceService.create(information);
      }
    }
    ...
    
    
  4. Xử lý logic client disconnect

    Vẫn là trong app.gateway.ts

    @WebSocketGateway(3006, { cors: true })
    export class AppGateway
    {
      ...
      
      async handleDisconnect(client: Socket) {
        const user = await this.getDataUserFromToken(client);
        await this.deviceService.deleteByValue(user.id, client.id);
    
        // need handle remove socketId to table
        this.logger.log(client.id, 'Disconnect');
      }
    
        await this.deviceService.create(information);
      }
    }
    ...
    
    
  5. Xử lý Listen message và emit message to client

       @WebSocketGateway(3006, { cors: true })
       export class AppGateway
       {
         ...
    
         @SubscribeMessage('messages')
         async messages(client: Socket, payload: MessagesInterface) {
          // get all user trong conversation bằng conversation_id
           const conversation = await this.conversationService.findById(
             payload.conversation_id,
             ['users'],
           );
           
           // get all socket id đã lưu trước đó của các user thuộc conversation
           const dataSocketId = await this.deviceService.findSocketId(userId);
           
           // Lưu dữ liệu vào bảng message
           const message = await this.messageService.create({
             user_id: payload.user_id,
             status: false,
             message: payload.message,
             conversation_id: payload.conversation_id,
             createdAt: new Date(),
             updatedAt: new Date(),
           });
           
           //emit message đến socket_id
           dataSocketId.map((value) => {
             emit.to(value.value).emit('message-received', {
               id: message.id,
               message: message.message,
               conversation_id: message.conversation_id,
               user_id: message.user_id,
               status: message.status,
               createdAt: message.createdAt,
               updatedAt: message.updatedAt,
             });
           });
     }
    }
    ...
    
    

4. Client Side

       > npm i socket.io-client

       > npm i redux-saga

       > npm i @reduxjs/toolkit

       > npm i redux
  1. Setting Redux + Redux saga:

    store.ts :

    const rootReducer = combineReducers({
        router: connectRouter(history),
        auth: authReducer,
        chat: chatReducer,
      })
    
      const sagaMiddleware = createSagaMiddleware()
      export const store = configureStore({
        reducer: rootReducer,
        middleware: (getDefaultMiddleware) =>
          getDefaultMiddleware().concat(sagaMiddleware, routerMiddleware(history)),
      });
    
      sagaMiddleware.run(rootSaga)
      
      export function* rootSaga() {
        yield all([
          authSaga(),
          chatSaga(),
        ])
      }
      
      export type AppDispatch = typeof store.dispatch;
      export type RootState = ReturnType<typeof store.getState>;
      export type AppThunk<ReturnType = void> = ThunkAction<
        ReturnType,
        RootState,
        unknown,
        Action<string>
      >;
    

    Tiếp đó cần use redux saga trong file App.tsx

        import {store} from './store'
        
        ReactDOM.render(
        // <React.StrictMode>
          <Provider store={store}>
            <ConnectedRouter history={history}>
               // thay vì dùng redux các bạn cũng có thể dùng Connect API cũng dễ dàng cho việc code hơn nhé
               // trong source mình cũng có để nhé
              {/*<SocketContext.Provider value={{socket}}>*/}
              
              // trong đây mình có dùng style componet material-ui các bạn cũng có thể tham khảo nhé
                <MuiThemeProvider theme={themes}>  
                  <App />
                  <RouterComponent />
                </MuiThemeProvider>
              {/*</SocketContext.Provider>*/}
            </ConnectedRouter>
          </Provider>,
        // </React.StrictMode>,
        document.getElementById('root')
      );
    
    
  2. Tiền hành xử lý logic khi 1 action được dispatch

    NOTE: ở đây mình có sử dụng redux toolkit mọi người có thể tìm hiều về nó trước khi đọc tiếp nhé

    export interface Message {
     id: number;
     user_id: number | string;
     conversation_id: number | string;
     message: string;
    }
    
    export interface Conversation {
     messages: Message[];
     id: number;
     title: string | null;
     sending: boolean;
    }
    
    const initialState: ListConversationState = {
         loading: false,
         error: '',
         conversations: [],
         loaded: false,
    }
    export const chatSlice = createSlice({
         name: 'chat',
         initialState,
         reducers: {
           sendMessage(state, action: PayloadAction<Message>) {
             state.conversations = state.conversations.map(conversation => {
                    if(conversation.id === action.payload.conversation.id ) {
                    // cái này để hiển thị sending
                        conversation.sending = true
                    }
                    return conversation;
             });
    
             return state;
           },
           
           sendMessageSuccess(state, action: PayloadAction<Message>) {
               // ở đây ta cần push message received vào list message của conversation đang activce 
               state.conversations = state.conversations.map(conversation => {
                    if(conversation.id === action.payload.conversation.id ) {
                    // cái này để hiển thị sending
                        conversation.sending = false
                          conversation.messages = conversation.messages 
                          ? [ action.payload,  ...conversation.messages]
                          : [action.payload]
                    }
                    return conversation;
             });
           }
       }
    })
    
  3. Saga middleware trong file chatSaga.ts :

    import { io, Socket } from 'socket.io-client';
    function connect() {
      const token = getAccessToken();
      const url = process.env.REACT_APP_SOCKET_URL ?? '';
      const socket = io(url, {
        query: { token }
      });
    
      return new Promise(resolve => {
        socket.on('connect', () => {
          // socket.emit('room', 'room1');
          resolve(socket);
        });
      })
    }
    
    //receive message
    function* read(socket: Socket) {
      while (true) {
            socket.on('message-received', (message) => {
            // dispatch sendMessageSuccess
             yield put(chatActions.sendMessageSuccess, message)
            });
        ;
      }
    }
    
    //handle send message
    function* send(socket: Socket) {
      while (true) {
        const { payload } = yield take(chatActions.sendMessage.type)
    
        socket.emit('messages', payload)
      }
    }
    
    function* handleIO(socket: Socket) {
      yield fork(read, socket);
      yield fork(send, socket);
    }
    
    function* flowSocket() {
      const socket: Socket = yield call(connect)
      
      // ta cần 1 task thực hiện send and receive message
      const task: Task = yield fork(handleIO, socket)
      
      // ở đây nếu logout thì cần close connect socket
      yield take(authAction.logout.type)
      yield cancel(task)
    
    }
    //flow 
    function* flow() {
      while (true) {
        const isLoggedIn = Boolean(getToken())
        const currentUser = Boolean(getUser());
        // ở đây mình cần check điều kiện đã đăng nhập chưa 
        if (isLoggedIn && currentUser) {
          // đã đăng nhập thì cho phép next
          yield call(flowSocket)
        } else {
         // nếu chưa đăng nhạp thì cần lắng nghe việc loginSuccess thì next
          yield take(authAction.loginSuccess)
          yield call(flowSocket)
        }
      }
    }
    
    //root handle
    export default function* chatSaga() {
      yield fork(flow)
    }
    
  4. Sử dụng trong component

        const Index: React.FC = () => {
         const dispatch = useDispatch();
         const chat: ListConversationState = useSelector((state: RootState) => state.chat)
         // const { socket } = useContext(SocketContext);
         const [message, setMessage] = useState('')
    
         const sendData = () => {
             dispatch(chatActions.sendMessage({
                   message,
                   conversation_id: conversationActive.id,
                   user_id: getUser().id,
               }))
           setMessage('')
         }
    
         return (
           <div>
             {
                chat.messages && chat.messages.map((message, index) => <p key={index}>{message.message}</p>)
             }
             <input type='text' value={message} onChange={e => setMessage(e.target.value)} />
             <button onClick={sendData}>
               {/*{ chat.sending ? 'Sending.........' : 'Send'}*/}
               send
             </button>
           </div>
         )
       }
    

5. Kết quả và kết luận

Cuối cùng kết quá sẽ là :

Cảm ơn các bạn đã theo dõi series của mình.

Server side: tại đây

Client side: tại đây

Sắp tới mình đang định xây dựng series về Nextjs mời các bạn đón đọc


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí