# 5. 설계 ## 5.1 UML ### 5.1.1 Server Side UML ```mermaid classDiagram class PermissionDescriptor { +canRead(path: string): boolean +canWrite(path: string): boolean +canCustom(path: string, options: any): boolean } <> PermissionDescriptor PermissionDescriptor <|.. PermissionImpl class PermissionImpl { +basePath: string +writable: boolean +canRead(path: string): boolean +canWrite(path: string): boolean +canCustom(_path: string, _options: any): boolean } SessionStore o-- UserSession UserSession *-- PermissionDescriptor class SessionStore~T~ { +sessions: Record +get(id: string): T +set(id: string, value: T): void +delete(id: string): void +saveToFile(path: string): Promise +loadFromFile(path: string): Promise } class UserSession { +id: string +superuser: boolean +expiredAt: number +permissionSet: PermissionDescriptor } <> UserSession ``` ```mermaid classDiagram Router <|.. FileServeRouter class FileServeRouter { +fn: Handler +match(path: string, _ctx: MatchContext): any } class MethodRouterBuilber { +handlers: MethodRouter +get(handler: Handler): this +post(handler: Handler): this +put(handler: Handler): this +delete(handler: Handler): this +build(): Handler } class ResponseBuilder { +status: Status +headers: Record +body?: BodyInit +setStatus(status: Status): this +setHeader(key: string, value: string): this +setBody(body: BodyInit): this +redirect(location: string): this +build(): Response } class MatchContext <> MatchContext class Router~T~ { +match(path: string, ctx: MatchContext): T } <> Router Router <|.. TreeRouter class TreeRouter~T~ { -staticNode: Record> -simpleParamNode?: SimpleParamNode -regexParamNodes: Map> -fallbackNode?: Router -elem?: T -findRouter(path: string, ctx: MatchContext): [TreeRouter, string] +match(path: string, ctx?: MatchContext): T +register(path: string, elem: T): this +registerRouter(path: string, router: Router): void -setOrMerge(elem: RegElem): void -registerPath(path: string, elem: RegElem): void -singleRoute(p: string): SingleRouteOutput } MethodRouter <-- MethodRouterBuilber: Create Router <|.. MethodRouter Router <|.. FsRouter Response <-- ResponseBuilder: Create ``` ```mermaid classDiagram class ChunkMethodAction { +action: (doc: ActiveDocumentObject) => ChunkMethodHistory +checkConflict: (m: ChunkMethodHistory) => boolean +trySolveConflict: (m: ChunkMethodHistory) => boolean } <> ChunkMethodAction ChunkMethodAction <|.. ChunkCreateAction class ChunkCreateAction { +params: ChunkCreateMethod +action(doc: ActiveDocumentObject): ChunkCreateHistory +checkConflict(m: ChunkMethodHistory): boolean +trySolveConflict(m: ChunkMethodHistory): boolean } ChunkMethodAction <|.. ChunkDeleteAction class ChunkDeleteAction { +params: ChunkDeleteMethod +action(doc: ActiveDocumentObject): ChunkRemoveHistory +checkConflict(_m: ChunkMethodHistory): boolean +trySolveConflict(_m: ChunkMethodHistory): boolean } ChunkMethodAction <|.. ChunkModifyAction class ChunkModifyAction { +params: ChunkModifyMethod +action(doc: ActiveDocumentObject): ChunkModifyHistory +checkConflict(m: ChunkMethodHistory): boolean +trySolveConflict(_m: ChunkMethodHistory): boolean } ChunkMethodAction <|.. ChunkMoveAction class ChunkMoveAction { +params: ChunkMoveMethod +action(doc: ActiveDocumentObject): ChunkMoveHistory +checkConflict(_m: ChunkMethodHistory): boolean +trySolveConflict(_m: ChunkMethodHistory): boolean } ``` ```mermaid classDiagram DocumentObject <|.. FileDocumentObject class FileDocumentObject { +docPath: string +chunks: Chunk[] +tags: string[] +updatedAt: number +tagsUpdatedAt: number +open(): Promise +parse(content: unknown[]): void +save(): Promise } class Participant { +id: string +user: UserSession +send(data: string): void +addEventListener(type: T, listener: (this: WebSocket, event: WebSocketEventMap[T]) => void): void +removeEventListener(type: T, listener: (this: WebSocket, event: WebSocketEventMap[T]) => void): void +close(): void } <> Participant Participant <|.. Connection class Connection { +id: string +user: UserSession +socket: WebSocket +send(data: string): void +addEventListener(type: T, listener: (this: WebSocket, event: WebSocketEventMap[T]) => void): void +removeEventListener(type: T, listener: (this: WebSocket, event: WebSocketEventMap[T]) => void): void +close(code?: number, reason?: string): void } class ParticipantList { +connections: Map +add(id: string, p: Participant): void +get(id: string): any +remove(id: string): void +unicast(id: string, message: string): void +broadcast(message: string): void } FileDocumentObject <|-- ActiveDocumentObject class ActiveDocumentObject { +conns: Set +history: DocHistory[] +maxHistory: number +join(conn: Participant): void +leave(conn: Participant): void +updateDocHistory(method: ChunkMethodHistory): void +broadcastMethod(method: ChunkMethod, updatedAt: number, exclude?: Participant): void } class DocumentStore { +documents: Inline +open(conn: Participant, docPath: string): Promise +close(conn: Participant, docPath: string): void +closeAll(conn: Participant): void } ParticipantList o-- Participant DocumentStore o-- ActiveDocumentObject ``` ```mermaid classDiagram class IFsWatcher{ addEventListener(): void onNofity(e: FileWatchEvent): void } <> IFsWatcher class FsWatcherImpl{ onNotify(e: FileWatchEvent): void } ``` ### 5.1.2 Client Side UML ```mermaid classDiagram Error <|-- RPCErrorWrapper class RPCErrorWrapper { +data?: unknown +code: RPCErrorCode +toJSON(): Inline } MessageEvent <|-- RPCNotificationEvent class RPCNotificationEvent { +notification: ChunkNotification } ``` ```mermaid classDiagram class ViewModelBase { +updateAsSource(path: string, updatedAt: number): void } <> ViewModelBase ViewModelBase <|.. IViewModel class IViewModel { +pageView: IPageViewModel } <> IViewModel IPageViewModel <|.. BlankPage class BlankPage { +type: string +updateAsSource(_path: string, _updatedAt: number): void } IViewModel <|.. ViewModel class ViewModel { +pageView: IPageViewModel +updateAsSource(path: string, updatedAt: number): void } ViewModelBase <|.. IPageViewModel class IPageViewModel { +type: string } <> IPageViewModel IPageViewModel <|.. IDocumentViewModel class IDocumentViewModel { +updateOnNotification(notification: ChunkNotification): void } <> IDocumentViewModel class ChunkListMutator { +add(i?: number | undefined, chunkContent?: ChunkContent | undefined): void +create(i?: number | undefined): void +addFromText(i: number, text: string): void +del(id: string): void +move(id: string, pos: number): void } <> ChunkListMutator class ChunkListState { +chunks: Chunk[] +cloen(): ChunkListState } class ChunkListStateMutator <> ChunkListStateMutator class ChunkListHistory { +history: ChunkListHistoryElem[] +limit: number -applyLast(mutator: ChunkListStateMutator, updatedAt: number): void +current: ChunkListState +currentUpdatedAt: number +revoke(): void +apply(mutator: ChunkListStateMutator, updatedAt: number): boolean } class ChunkMutator { +setType(t: ChunkContentType): void +setContent(s: string): void } <> ChunkMutator IDocumentViewModel <|.. DocumentViewModel class DocumentViewModel { +type: "document" +docPath: string +chunks: Chunk[] +history: ChunkListHistory +buffer: Inline +seq: number +tags: string[] +updatedAt: number +tagsUpdatedAt: number +updateOnNotification(notification: ChunkNotification): void -updateMark(updatedAt: number): void +apply(mutator: ChunkListStateMutator, updatedAt: number, seq: number, refresh?: boolean): void +updateAsSource(_path: string, _updatedAt: number): void +useChunks(): [Chunk[], ChunkListMutator] +useTags(): [string[], (tags: string[]) => Promise] +useChunk(chunk_arg: Chunk): [Chunk, ChunkMutator] } IViewModel ..> "1" IPageViewModel ViewModel ..> "1" IPageViewModel ChunkListHistory ..> "1" ChunkListStateMutator ChunkListHistory ..> "1" ChunkListState DocumentViewModel ..> "1" ChunkListHistory DocumentViewModel ..> "1" ChunkListStateMutator DocumentViewModel ..> "1" ChunkListMutator DocumentViewModel ..> "1" ChunkMutator ``` ```mermaid classDiagram class ChunkListStateMutator <> ChunkListStateMutator class ChunkListStateAddMutator class ChunkListStateDeleteMutator class ChunkListStateModifyMutator class ChunkListStateMoveMutator ChunkListStateMutator <|.. ChunkListStateAddMutator ChunkListStateMutator <|.. ChunkListStateDeleteMutator ChunkListStateMutator <|.. ChunkListStateModifyMutator ChunkListStateMutator <|.. ChunkListStateMoveMutator ``` ```mermaid classDiagram class IRPCMessageManager { +opened: boolean +close(): void +sendNotification(notification: ChunkNotification): void +addEventListener(name: "notification", listener: RPCMessageMessagerEventListener): void +addEventListener(name: string, listener: EventListenerOrEventListenerObject): void +removeEventListener(name: "notification", listener: RPCMessageMessagerEventListener): void +removeEventListener(name: string, listener: EventListenerOrEventListenerObject): void +invokeMethod(m: RPCMessageBody): Promise } <> IRPCMessageManager IRPCMessageManager <|.. RPCMessageManager class RPCMessageManager { -callbackList: Map -curId: number -ws?: WebSocket | undefined +opened: boolean +open(url: string | URL, protocals?: string | undefined): Promise +close(): void -genId(): number +genHeader(): Inline +send(message: RPCMethod): Promise +sendNotification(message: ChunkNotification): void +addEventListener(type: "notification", callback: RPCMessageMessagerEventListener): void +removeEventListener(type: "notification", callback: RPCMessageMessagerEventListener): void +invokeMethod(m: RPCMessageBody): Promise } class FsDirEntry { +name: string +isDirectory: boolean +isFile: boolean +isSymlink: boolean } <> FsDirEntry class FsStatInfo { +isFile: boolean +isDirectory: boolean +isSymlink: boolean +size: number +mtime: Date | null +atime: Date | null +birthtime: Date | null } <> FsStatInfo FsStatInfo <|.. FsGetResult class FsGetResult { +entries?: FsDirEntry[] | undefined } <> FsGetResult class IFsEventMap { +modify: (this: IFsManager, event: MessageEvent) => void +create: (this: IFsManager, event: MessageEvent) => void +delete: (this: IFsManager, event: MessageEvent) => void } <> IFsEventMap class IFsManager { +get(path: string): Promise +getStat(path: string): Promise +upload(filePath: string, data: BodyInit): Promise +delete(filePath: string): Promise +mkdir(path: string): Promise +addEventListener(name: string, listener: EventListenerOrEventListenerObject): void +removeEventListener(name: string, listener: EventListenerOrEventListenerObject): void +dispatchEvent(event: Event): boolean } <> IFsManager IFsManager <|.. FsManager class FsManager { -manager: RPCMessageManager -prefix: string +get(path: string): Promise +getStat(filePath: string): Promise +upload(filePath: string, data: BodyInit): Promise +mkdir(filePath: string): Promise +delete(filePath: string): Promise } FsGetResult ..> "*" FsDirEntry IFsEventMap ..> "1" IFsManager IFsManager ..> "1" FsGetResult FsManager ..> "1" RPCMessageManager FsManager ..> "1" FsGetResult ``` ## 5.2 의사코드 의사코드는 다음과 같이 진행된다. ### 서버 RPC 메세지 처리 클라이언트는 RPC를 진행하기 위해 웹소켓을 연결합니다. 웹소켓의 주소 `/ws`에 도달하기 위해서 먼저 라우팅이 진행이 됩니다. 라우팅은 `TreeRouter` 클래스에서 진행됩니다. ``` Input: req Request Output: Response fn Route(req){ for node in HandlerNodes { if(req.url prefix is matching){ node.Handler[matching](req) } } response with 404 Not Found } ``` 마침내 엔드포인트에 도달하게 되면 웹소켓을 얻어내고 새로운 연결을 등록합니다. ``` Input: req Request Output: Response fn RPCHandleEndpoint(req){ ws, res = upgradeWebSocket(req) user = getUserInfo(req.header) conn = new Connection(ws, user) registerParticipant(conn) res } ``` 이제부터 메세지를 받을 수 있습니다. 메세지가 오면 이 함수가 실행이 됩니다. ``` Input: message on send Output: response fn Connection.handleMessage(msg: string){ data = JSON.parse(msg) check format of data dispatch(data.method, data, this) } ``` 디스패치가 성공적으로 이루어지면 RPC 작업를 처리하는 함수에 도착합니다. 청크 작업을 예로 들겠습니다. 청크 작업에서는 권한을 확인하고 명령의 충돌을 히스토리를 비교하며 다시 적용하며 해결합니다. 그리고 요구된 작업을 처리하고 문서의 `updatedAt`을 업데이트하고 문서 업데이트 사실을 이 문서를 보고 있던 참여자에게 전파합니다. ``` Input: conn Connection Input: m RPCChunkMethod Output: response fn ChunkOperation(conn, m){ doc = DocStore.getDocument(m.docPath); updatedAt = m.updatedAt; action = getAction(m); if !conn.userpermissionSet.canDo(Action) { response with PermissionError; } appliedList = doc.history.filter(x => x.updatedAt > updatedAt) for m in appliedList { if action.checkConflict(m) { resolvedAction = action.tryResolveConflict(m) if resolvedAction == Fail { response with ConflictError } action = resolvedAction } } res = action.act(doc); doc.updateHistory(res); subscribers = doc.getSubscribers(); subscribers.broadcastNotification(m, exclude = conn); response with res } ``` 문서 작업도 마찬가지로 이루어집니다. ### 클라이언트의 메세지 처리 동기화 클라이언트에서는 다음과 같은 일이 일어납니다. 먼저 `notification`을 받습니다. 그러면 모든 `DocumentViewModel`에게 이벤트를 전달합니다. 그리고 각각의 `DocumentViewModel`은 자기 문서에 일어난 일인지 확인하고 `ChunkListMutator`를 만들어서 문서에 적용합니다. ``` Input: e RPCNotification Input: this document view model fn updateOnNotification(this,notification){ { docPath, method, seq, updatedAt } = notification.params; if (docPath !== this.docPath) return; mutator = ChunkListMutatorFactory.createFrom(method); this.apply(mutator, updatedAt, seq); } ``` 문서의 레디큐에 mutator를 집어넣고 `seq` 번호가 기다리는 것이면 실행하고 업데이트합니다. ``` Input: mutator ChunkListMutator Input: updatedAt Date Input: seq number Input: this Document View Model //readyQueue is priority queue. fn apply(this, mutator, updatedAt, seq){ this.readyQueue.push({mutator, updatedAt, seq}) if(this.readyQueue.lenght < some limit){ while(!readyQueue.empty() && this.readyQueue.top().seq === this.seq + 1) { mutator, updatedAt, seq = this.readyQueue.pop(); mutator(this.chunks); this.seq = seq; this.updatedAt = updatedAt; } this.dispatch(new Event("chunksChange")); } else { document.refresh(); } } ``` ### 다른 작업들 ``` module chunk { type mode = Read | Write struct Chunk { id: string content: string type: string } newChunk() { { id = uuid() ; content = "" ; type = "" } } chunkViewer(chunk : Chunk, focusedChunk : State, deleteThis : () => void) : Component { var mode = Read var c = new Component( content { value = chunk.content } settypebutton editbutton deletebutton ) when mode becomes Read { chunk.content = content } when mode becomes Write { focusedChunk = chunk.id } when focusedChunk is changed { mode = Read } when editbutton is clicked { mode = (mode = Read) ? Write : Read } when deletebutton is clicked { deleteThis() } when settypebutton is clicked { chunk.type = prompt() } return c } } ``` ``` module search { searchWord(chunks, word) { return doc.chunks.concat_map((s) => s.matchAll(word)) } searchWordPrompt(chunks: Chunk.chunk list) { var word = prompt() var results = searchWord(chunks, word) var c = new Component(results) when result in results is selected { moveto(result.location) close() } } when Ctrl-F is pressed { searchWordPrompt() } } ``` ``` module document { struct Document { title: string path: Path tags: string set chunks: chunk.Chunk array } documentViewer(doc: document) : Component { var focusedChunk = null var c = new Component( taglist { value: tags } chunklist ) delete(id) { i = doc.chunks.find((c) => c.id = id) doc.chunks.remove(i) } chunklist = doc.chunks.concat_map((c, i) => [ divider(i), chuknViewer(c, focusedChunk, () => delete(c.id)) ]) when divider(i) clicked { doc.chunks.insert(i, c) } when chunkViewer(c) is dropped on divider(i) { doc.chunks.move(c, i) } return c } } ``` ``` module filelist { fileList(dir : Directory, open: (File) => void) : Component { var c = new Component( filelist ) filelist = dir.files().map((f) => button(f)) when button(f) is clicked { open(f) } return c } } ``` ``` module settings { settings() : Component { var c = new Component( language = select("korean", "english") theme = select("light", "dark") ) when language(l) is selected { global context.lang = l } when theme(t) is selected { global context.theme = t } return c } } ``` module frontend { main() : Component { var docv var open = (f) => { with doc = openfile(f) { docv = document.documentViewer(doc) } } var filelist = filelist.fileList(rootdir, open) var c = new Component( document = docv filelist = filelist ) return c } } ```