Chat Application

1. Outline

Chat Application

간단한 실시간 채팅 어플리케이션을 만들어봅니다.

네트워크 프로그래밍을 실습할 때, 대부분은 실시간 채팅 어플리케이션을 만드는 것에서부터 시작하더라구요. 저희도 그렇게 하겠습니다. TGrid 의 첫 예제 프로젝트로써, 실시간 채팅 서버 프로그램을 만들겠습니다. 채팅에 참여할 클라이언트 프로그램 (웹 어플리케이션) 역시 만들어볼 것입니다.

단, Chat ApplicationTGrid 의 첫 예제 프로젝트인만큼, 난이도를 매우 낮춰 아주 간단하게 만들도록 하겠습니다. 서버에는 오로지 단 하나의 채팅방만이 존재할 것입니다. 따라서 해당 서버에 접속하는 모든 클라이언트들 다같이 모여 대화하게 됩니다. 클라이언트 어플리케이션 역시, ReactJS 로 매우 간결하게 만들 것입니다.

만일 다수의 방이 존재하는 채팅 어플리케이션의 코드가 궁금하시다면, 이 다음 예제 프로젝트인 Othello Game 을 참고해주세요. 물론, 제목은 보시다시피 오델로 게임이지만, 게임 내에 다수의 대진방이 존재하며 각 대진방에서는 대국자와 관람자들이 상호 대화할 수 있습니다.

2. Design

Class Diagram

우리가 처음으로 만들어볼 Chat Application 은 매우 간단합니다. 서버에는 오로지 단 하나의 채팅방만이 존재하며, 해당 서버에 접속하는 모든 클라이언트는 오로지 이 곳에서 서로 대화하게 됩니다. 따라서 서버와 클라이언트가 각각 서로에게 제공해야 할 기능 또한 매우 간결합니다.

서버가 클라이언트에게 제공해야 할 기능이란, 그저 클라이언트가 자신의 이름 (닉네임) 을 설정하고 채팅방 본연의 목적인 대화를 나누는 것이 전부입니다. 클라이언트가 서버에게 제공하는 기능은 훨씬 더 쉽고 간단하여, 이를 단 한 마디로 요약할 수 있습니다. 그것은 바로 클라이언트에게 채팅방에서 일어난 일을 알려주는 것입니다.

  • 서버가 클라이언트에게 제공해야 할 기능
    • 이름 (닉네임) 설정
    • 모두에게 대화하기
    • 개인에게 귓속말하기
  • 클라이언트가 서버에게 제공해야 할 기능
    • 참여자 추가 및 삭제
    • 전체 대화 출력하기
    • 귓속말 출력하기

3. Implementation

3.1. Features

controllers/IChatService.ts

IChatService 는 서버가 클라이언트에게 제공하는 기능들을 정의한 인터페이스로써, 클라이언트에서 Controller 로 사용됩니다. 정확히는 클라이언트 프로그램이 Driver<IChatService> 객체를 사용하여 서버의 함수들을 원격 호출하게 됩니다.

자, 그럼 서버에 접속한 클라이언트가 제일 먼저 해야할 일은 무엇일까요? 그것은 바로 자신의 이름을 정하는 일입니다 (setName). 이후에 클라이언트는 채팅방에 참여한 모두에게 이야기하거나 (talk), 특정한 누군가에게 귓속말로 속삭이거나 하겠지요 (whisper).

export interface IChatService
{
    /**
     * 이름 설정하기
     * 
     * @param name 설정할 이름값
     * @return 채팅방 참여자 리스트, 중복 이름 존재시 false 리턴
     */
    setName(name: string): string[] | boolean;

    /**
     * 모두에게 이야기하기
     * 
     * @param content 이야기할 내용
     */
    talk(content: string): void;

    /**
     * 귓속말로 속삭이기
     * 
     * @param to 속삭일 대상의 이름
     * @param content 속삭일 내용
     */
    whisper(to: string, content: string): void;
}

controllers/IChatPrinter.ts

IChatPrinter 는 클라이언트가 서버에게 제공하는 기능들을 정의한 인터페이스로써, 서버에서 Controller 로 사용됩니다. 정확히는 서버 프로그램이 Driver<IChatPrinter> 객체를 사용하여 클라이언트가 (서버에게) 제공하는 함수들을 원격 호출하게 됩니다.

더불어 IChatPrinter 에 정의된 함수들을 보면, 클라이언트가 서버에게 무엇을 원하는지, 무슨 이유로 저러한 함수들을 서버에게 제공하는지, 그 목적이 뚜렷하게 보입니다. 그것은 바로 "서버야, 채팅방에서 일어난 일들을 나에게 알려줘" 입니다.

서버는 Driver<IChatPrinter> 의 inserterase 함수를 호출함으로써, 클라이언트에게 새로운 참여자가 입장하였거나 기존의 참여자가 퇴장했다는 사실을 알려줍니다. 그리고 talkwhisper 함수들을 호출함으로써, 클라이언트에게 대화방에서 이루어지는 대화내역들 역시 전달해줄 수 있습니다.

export interface IChatPrinter
{
    /**
     * 새 참여자 추가
     */
    insert(name: string): void;

    /**
     * 기존 참여자 삭제
     */
    erase(name: string): void;

    /**
     * 모두에게 대화하기 출력
     * 
     * @param from 발언자
     * @param content 내용
     */
    talk(from: string, content: string): void;

    /**
     * 귓속말 내용 출력
     * 
     * @param from 발언자
     * @param to 청취자
     * @param content 내용
     */
    whisper(from: string, to: string, content: string): void;
}

3.2. Server Program

providers/ChatService.ts

ChatService 는 서버가 클라이언트에게 제공하는 Provider 클래스입니다.

동시에 클라이언트가 Driver<IChatService> 를 사용하여 ChatService 의 메서드를 원격 호출할 때마다, ChatService 는 이를 모든 참여자 (클라이언트) 들에게 전파하는 역할도 맡고 있습니다. 이 때 사용하게 되는 멤버변수는 participants_: HashMap<string, Driver<IChatPrinter>> 입니다.

클라이언트가 서버와의 접속을 종료하거든, 서버의 메인 함수ChatService.destructor() 메서드를 호출함으로써, 클라이언트의 퇴장을 여타 모든 참여자들에게 알려주게 됩니다. 이 때에 사용하게 되는 멤버 변수도 역시 participants_: HashMap<string, Driver<IChatPrinter>> 입니다.

import { Driver } from "tgrid/components/Driver";
import { HashMap } from "tstl/container/HashMap";

import { IChatService } from "../controllers/IChatService";
import { IChatPrinter } from "../controllers/IChatPrinter";

export class ChatService implements IChatService
{
    private participants_: HashMap<string, Driver<IChatPrinter>>;
    private printer_: Driver<IChatPrinter>;
    private name_?: string;

    /* ----------------------------------------------------------------
        CONSTRUCTORS
    ---------------------------------------------------------------- */
    public constructor
        (
            participants: HashMap<string, Driver<IChatPrinter>>, 
            printer: Driver<IChatPrinter>
        )
    {
        this.participants_ = participants;
        this.printer_ = printer;
    }

    public destructor(): void
    {
        if (this.name_ === undefined)
            return;

        // ERASE FROM PARTICIPANTS
        this.participants_.erase(this.name_);

        // INFORM TO OTHERS
        for (let it of this.participants_)
        {
            let p: Promise<void> = it.second.erase(this.name_);
            p.catch(() => {});
        }
    }

    /* ----------------------------------------------------------------
        INTERACTIONS
    ---------------------------------------------------------------- */
    public setName(name: string): string[] | false
    {
        if (this.participants_.has(name))
            return false;

        // ASSIGN MEMBER
        this.name_ = name;
        this.participants_.emplace(name, this.printer_);

        // INFORM TO PARTICIPANTS
        for (let it of this.participants_)
        {
            let printer: Driver<IChatPrinter> = it.second;
            if (printer === this.printer_)
                continue;

            let promise: Promise<void> = printer.insert(name);
            promise.catch(() => {});
        }
        return [...this.participants_].map(it => it.first);
    }

    public talk(content: string): void
    {
        // MUST BE NAMED
        if (this.name_ === undefined)
            throw new Error("Name is not specified yet.");

        // INFORM TO PARTICIPANTS
        for (let it of this.participants_)
        {
            let p: Promise<void> = it.second.talk(this.name_, content);
            p.catch(() => {});
        }
    }

    public async whisper(to: string, content: string): Promise<void>
    {
        // MUST BE NAMED
        if (this.name_ === undefined)
            throw new Error("Name is not specified yet.");
        else if (this.participants_.has(to) === false)
            throw new Error("Unable to find the matched name");

        //----
        // INFORM TO PARTICIPANTS
        //----
        // TO SPEAKER
        let from: string = this.name_;
        this.printer_.whisper(from, to, content).catch(() => {});

        // TO LISTENER
        if (from !== to)
        {
            let target: Driver<IChatPrinter> = this.participants_.get(to);
            target.whisper(from, to, content).catch(() => {});
        }
    }
}

server.ts

서버 프로그램의 메인 코드는 정말 간단합니다.

웹소켓 서버를 개설하고, 접속해오는 각 클라이언트들에게 ChatService 객체를 Provider 로써 제공해주면 됩니다. 그리고 클라이언트가 접속을 종료했을 때, ChatService.destructor() 메서드를 호출하여, 해당 클라이언트를 채팅방의 참여자 목록에서 제거해주시면 됩니다.

import { WebServer, WebAcceptor } from "tgrid/protocols/web";
import { Driver } from "tgrid/components/Driver";
import { HashMap } from "tstl/container/HashMap";

import { IChatPrinter } from "./controllers/IChatPrinter";
import { ChatService } from "./providers/ChatService";
import { Global } from "./Global";

async function main(): Promise<void>
{
    let server: WebServer<ChatService> = new WebServer();
    let participants: HashMap<string, Driver<IChatPrinter>> = new HashMap();

    await server.open(Global.PORT, async (acceptor: WebAcceptor<ChatService>) =>
    {
        // PROVIDE SERVICE
        let driver: Driver<IChatPrinter> = acceptor.getDriver<IChatPrinter>();
        let service: ChatService = new ChatService(participants, driver);

        await acceptor.accept(service);

        // DESTRUCTOR
        await acceptor.join();
        service.destructor();
    });
}
main();

3.3. Client Application

providers/ChatPrinter.ts

ChatPrinter 는 클라이언트가 서버에 제공하는 Provider 클래스입니다.

ChatPrinter 는 서버가 Driver<IChatPrinter> 를 사용하여 자신의 메서드를 원격 호출할 때마다, 해당 내역을 자신의 멤버 변수에 기록해둡니다. 그리고 자신에게 할당된 이벤트 리스너 (멤버변수 listener_: ()=>void, 메서드 assign() 을 통하여 등록할 수 있다) 를 호출하여, 채팅방에 무언가 변화가 있음을 ChatMovie 에게 알려줍니다.

import { IChatPrinter } from "../controllers/IChatPrinter";

import { HashSet } from "tstl/container/HashSet";

export class ChatPrinter implements IChatPrinter
{
    private listener_?: ()=>void;

    public readonly name: string;
    public readonly participants: HashSet<string>;
    public readonly messages: ChatPrinter.IMessage[];

    /* ----------------------------------------------------------------
        CONSTRUCTOR
    ---------------------------------------------------------------- */
    public constructor(name: string, participants: string[])
    {
        this.name = name;
        this.participants = new HashSet(participants);
        this.messages = [];
    }

    public assign(listener: ()=>void): void
    {
        this.listener_ = listener;
    }

    private _Inform(): void
    {
        if (this.listener_)
            this.listener_();
    }

    /* ----------------------------------------------------------------
        METHODS FOR REMOTE FUNCTION CALL
    ---------------------------------------------------------------- */
    public insert(name: string): void
    {
        this.participants.insert(name);
        this._Inform();
    }

    public erase(name: string): void
    {
        this.participants.erase(name);
        this._Inform();
    }

    public talk(from: string, content: string): void
    {
        this.messages.push({ 
            from: from, 
            content: content 
        });
        this._Inform();
    }

    public whisper(from: string, to: string, content: string): void
    {
        this.messages.push({ 
            from: from, 
            to: to,
            content: content 
        });
        this._Inform();
    }
}

export namespace ChatPrinter
{
    export interface IMessage
    {
        from: string;
        content: string;
        to?: string;
    }
}

app.tsx

클라이언트의 메인 프로그램 코드 역시 매우 짧고 간단합니다.

일단 웹소켓 채팅 서버에 접속합니다. 그리고 JoinMovie 를 이동합니다. JoinMovie 에서는 채팅방에 참여하기 위하여 자신의 이름 (닉네임) 을 정하는 단계인데, 이 곳에서는 또 무슨 일이 일어나는지, 다음 절을 통해 한 번 알아볼까요?

import "core-js";
import React from "react";
import ReactDOM from "react-dom";

import { WebConnector } from "tgrid/protocols/web/WebConnector";

import { Global } from "./Global";
import { JoinMovie } from "./movies/JoinMovie";

window.onload = async function ()
{
    let connector: WebConnector = new WebConnector();
    await connector.connect(`ws://${window.location.hostname}:${Global.PORT}`);

    ReactDOM.render
    (
        <JoinMovie connector={connector} />, 
        document.body
    );
}

movies/JoinMovie.tsx

JoinMovie 에서 사용자는 자신의 이름을 정합니다.

사용자가 자신의 이름을 입력하고 "Participate in" 버튼을 누르거든, Driver<IChatService> 를 통하여 ChatService.setName() 메서드를 원격 호출합니다. 리턴값의 타입이 기존의 채팅방 참여자들을 의미하는 string 배열이거든, 그 즉시로 ChatMovie 로 화면을 전환합니다.

import React from "react";
import ReactDOM from "react-dom";

import AppBar from "@material-ui/core/AppBar";
import Button from "@material-ui/core/Button";
import Container from "@material-ui/core/Container";
import IconButton from "@material-ui/core/IconButton";
import Input from "@material-ui/core/Input";
import Toolbar from "@material-ui/core/Toolbar";
import Typography from "@material-ui/core/Typography";

import GitHubIcon from "@material-ui/icons/GitHub";
import MenuBookIcon from "@material-ui/icons/MenuBook";

import { WebConnector } from "tgrid/protocols/web/WebConnector";
import { Driver } from "tgrid/components/Driver";

import { IChatService } from "../controllers/IChatService";
import { ChatPrinter } from "../providers/ChatPrinter";
import { ChatMovie } from "./ChatMovie";
import { Global } from "../Global";

export class JoinMovie extends React.Component<JoinMovie.IProps>
{
    private get name_input_(): HTMLInputElement
    {
        return document.getElementById("name_input") as HTMLInputElement;
    }

    /* ----------------------------------------------------------------
        EVENT HANDLERS
    ---------------------------------------------------------------- */
    public componentDidMount()
    {
        this.name_input_.select();
    }

    private _Open_link(url: string): void
    {
        window.open(url, "_blank");
    }

    private _Handle_keyUp(event: React.KeyboardEvent): void
    {
        if (event.keyCode === 13)
            this._Participate();
    }

    private async _Participate(): Promise<void>
    {
        let input: HTMLInputElement = document.getElementById("name_input") as HTMLInputElement;
        let name: string = input.value;

        if (name === "")
        {
            alert("Name cannot be empty");
            return;
        }

        let connector: WebConnector = this.props.connector;
        let service: Driver<IChatService> = connector.getDriver<IChatService>();
        let participants: string[] | false = await service.setName(name);

        if (participants === false)
        {
            alert("Duplicated name");
            return;
        }

        let printer: ChatPrinter = new ChatPrinter(name, participants);
        connector.setProvider(printer);

        ReactDOM.render(<ChatMovie service={service} printer={printer} />, document.body);
    }

    /* ----------------------------------------------------------------
        RENDERER
    ---------------------------------------------------------------- */
    public render(): JSX.Element
    {
        return <React.Fragment>
            <AppBar>
                <Toolbar>
                    <Typography variant="h6"> Chat Application </Typography>
                    <div style={{ flexGrow: 1 }} />
                    <IconButton color="inherit" 
                                onClick={this._Open_link.bind(this, Global.BOOK)}>
                        <MenuBookIcon />
                    </IconButton>
                    <IconButton color="inherit"
                                onClick={this._Open_link.bind(this, Global.GITHUB)}>
                        <GitHubIcon />
                    </IconButton>
                </Toolbar>
            </AppBar>
            <Toolbar />
            <Container>
                <p> Insert your name: </p>
                <p>
                    <Input id="name_input" 
                           placeholder="Your Name"
                           onKeyUp={this._Handle_keyUp.bind(this)} /> 
                    {" "}
                    <Button color="primary" 
                            variant="outlined" 
                            onClick={this._Participate.bind(this)}> Enter </Button>
                </p>
            </Container>
        </React.Fragment>
    }

}
namespace JoinMovie
{
    export interface IProps
    {
        connector: WebConnector;
    }
}

movies/ChatMovie.tsx

ChatMovie 에서 본격적으로 대화가 이루어집니다.

이 곳에서 사용자가 대화를 입력하거나, 또는 특정 대상에게 귓속말로 속삭이거든, ChatMovie 는 그 즉시로 Driver<IChatService> 를 통하여 서버의 함수를 원격 호출합니다; ChatService.talk() 또는 ChatService.whisper().

또한 ChatMovieChatPrinter 에 이벤트 리스너를 등록해놨습니다. 그리고 이벤트 리스너는 호출될 때마다, 그 즉시로 화면을 갱신합니다. 따라서 채팅 서버에 참여한 다른 이들의 유입/이탈 이나 대화내역도 ChatPrinter 를 통하여 실시간으로 인지, 가장 최신 상태의 화면을 지속적으로 유지할 수 있습니다.

import React from "react";

import AppBar from "@material-ui/core/AppBar";
import Badge from "@material-ui/core/Badge";
import Button from "@material-ui/core/Button";
import Container from "@material-ui/core/Container";
import Drawer from "@material-ui/core/Drawer";
import IconButton from "@material-ui/core/IconButton";
import Input from "@material-ui/core/Input";
import List from "@material-ui/core/List";
import ListItem from "@material-ui/core/ListItem";
import ListItemText from "@material-ui/core/ListItemText";
import Toolbar from "@material-ui/core/Toolbar";
import Typography from "@material-ui/core/Typography";

import PeopleAltIcon from '@material-ui/icons/PeopleAlt';
import PersonOutlineIcon from '@material-ui/icons/PersonOutline';
import RecordVoiceOverIcon from '@material-ui/icons/RecordVoiceOver';
import SendIcon from '@material-ui/icons/Send';

import { Driver } from "tgrid/components/Driver";
import { HashSet } from "tstl/container/HashSet";

import { IChatService } from "../controllers/IChatService";
import { ChatPrinter } from "../providers/ChatPrinter";
import ListItemIcon from "@material-ui/core/ListItemIcon";

export class ChatMovie
    extends React.Component<ChatMovie.IProps>
{
    private whisper_to_: string | null = null;
    private show_participants_: boolean = false;

    private get input_(): HTMLInputElement
    {
        return document.getElementById("message_input") as HTMLInputElement;
    }

    /* ----------------------------------------------------------------
        CONSTRUCTOR
    ---------------------------------------------------------------- */
    public constructor(props: ChatMovie.IProps)
    {
        super(props);

        // WHENEVER EVENT COMES
        let printer: ChatPrinter = props.printer;
        printer.assign(() => 
        {
            // ERASE WHISPER TARGET
            if (this.whisper_to_ !== null && printer.participants.has(this.whisper_to_) === false)
                this.whisper_to_ = null;

            // REFRESH PAGE
            this.setState({})
        });
    }

    public componentDidMount()
    {
        let input: HTMLInputElement = document.getElementById("message_input") as HTMLInputElement;
        input.select();
    }

    public componentDidUpdate()
    {
        document.body.scrollTop = document.body.scrollHeight - document.body.clientHeight;
    }

    /* ----------------------------------------------------------------
        EVENT HANDLERS
    ---------------------------------------------------------------- */
    private _Handle_keyUp(event: React.KeyboardEvent<HTMLInputElement>): void
    {
        if (event.keyCode === 13)
            this._Send_message();
    }

    private _Send_message(): void
    {
        let content: string = this.input_.value;
        if (content === "")
            return;

        let service: Driver<IChatService> = this.props.service;

        if (this.whisper_to_ === null)
            service.talk(content);
        else
            service.whisper(this.whisper_to_, content);

        this.input_.value = "";
        this.input_.select();
    }

    protected _Select_participant(name: string): void
    {
        this.whisper_to_ = (this.whisper_to_ === name)
            ? null
            : name;

        this.input_.select();
        this.setState({});
    }

    private _Show_participants(flag: boolean): void
    {
        this.show_participants_ = flag;
        this.setState({});
    }

    /* ----------------------------------------------------------------
        RENDERER
    ---------------------------------------------------------------- */
    public render(): JSX.Element
    {
        let participants: JSX.Element = this._Render_participants();

        return <React.Fragment>
            {this._Render_body()}
            <Drawer anchor="right" 
                    open={this.show_participants_}
                    onClose={this._Show_participants.bind(this, false)}>
                {participants}
            </Drawer>
        </React.Fragment>;
    }

    private _Render_participants(): JSX.Element
    {
        let printer: ChatPrinter = this.props.printer;
        let myName: string = printer.name;
        let people: string[] = [ myName ];

        for (let person of printer.participants)
            if (person !== myName)
                people.push(person);

        return <div style={{ width: 225, padding: 15 }}>
            <div>
                <span style={{ fontSize: "x-large" }}> 
                    Participants 
                </span>: #{people.length}
            </div>
            <hr/>
            <Container>
                <List dense>
                {people.map(person =>
                    <ListItem button
                              selected={person === this.whisper_to_}
                              onClick={this._Select_participant.bind(this, person)}>
                        <ListItemIcon>
                        {person === this.whisper_to_
                            ? <RecordVoiceOverIcon />
                            : <PersonOutlineIcon />
                        }
                        </ListItemIcon>
                        <ListItemText>
                            <span style={{ fontWeight: (person === myName) ? "bold" : undefined }}>
                                {person} 
                            </span>
                        </ListItemText>
                    </ListItem>
                )}
                </List>
            </Container>
        </div>;
    }

    private _Render_body(): JSX.Element
    {
        let printer: ChatPrinter = this.props.printer;
        let participants: HashSet<string> = printer.participants;
        let messages: ChatPrinter.IMessage[] = printer.messages;

        return <React.Fragment>
            <AppBar>
                <Toolbar>
                    <Typography variant="h6"> Chat Application </Typography>
                    <div style={{ flexGrow: 1 }} />
                    <IconButton color="inherit" 
                                onClick={this._Show_participants.bind(this, true)}>
                        <Badge color="secondary"
                               badgeContent={participants.size()}>
                            <PeopleAltIcon />
                        </Badge>
                    </IconButton>
                </Toolbar>
            </AppBar>
            <Toolbar />
            <Container style={{ wordWrap: "break-word", paddingBottom: 50 }}>
                { messages.map(msg => this._Render_message(msg)) }
            </Container>
            <AppBar color="inherit" 
                    position="fixed" 
                    style={{ top: "auto", bottom: 0 }}>
                <Toolbar>
                    <Input id="message_input" 
                           fullWidth style={{ paddingRight: 0 }}
                           onKeyUp={this._Handle_keyUp.bind(this)}
                           placeholder={this.whisper_to_ ? `Whisper to '${this.whisper_to_}'` : "Talk to everyone"} />
                    <Button variant="contained" 
                            startIcon={ <SendIcon /> }
                            style={{ marginLeft: 10 }}
                            onClick={this._Send_message.bind(this)}> 
                        Send 
                    </Button>
                </Toolbar>
            </AppBar>
        </React.Fragment>;
    }

    private _Render_message(msg: ChatPrinter.IMessage): JSX.Element
    {
        let myName: string = this.props.printer.name;

        let fromMe: boolean = (msg.from === myName);
        let style: React.CSSProperties = {
            textAlign: fromMe ? "right" : undefined,
            fontStyle: msg.to ? "italic" : undefined,
            color: msg.to ? "gray" : undefined
        };
        let content: string = msg.content;

        if (msg.to)
        {
            let head: string = (msg.from === myName)
                ? `(whisper to ${msg.to})`
                : "(whisper)";
            content = `${head} ${content}`;
        }

        return <p style={style}>
            <b style={{ fontSize: 18 }}> {msg.from} </b>
            <br/>
            {content}
        </p>;
    }
}
export namespace ChatMovie
{
    export interface IProps
    {
        service: Driver<IChatService>;
        printer: ChatPrinter;
    }
}

results matching ""

    No results matching ""