2022-05-14 14:30:58 +09:00
|
|
|
# 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
|
|
|
|
}
|
|
|
|
<<Interface>> 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<string, T>
|
|
|
|
+get(id: string): T
|
|
|
|
+set(id: string, value: T): void
|
|
|
|
+delete(id: string): void
|
|
|
|
+saveToFile(path: string): Promise<void>
|
|
|
|
+loadFromFile(path: string): Promise<void>
|
|
|
|
}
|
|
|
|
class UserSession {
|
|
|
|
+id: string
|
|
|
|
+superuser: boolean
|
|
|
|
+expiredAt: number
|
|
|
|
+permissionSet: PermissionDescriptor
|
|
|
|
}
|
|
|
|
<<Interface>> 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<string, string>
|
|
|
|
+body?: BodyInit
|
|
|
|
+setStatus(status: Status): this
|
|
|
|
+setHeader(key: string, value: string): this
|
|
|
|
+setBody(body: BodyInit): this
|
|
|
|
+redirect(location: string): this
|
|
|
|
+build(): Response
|
|
|
|
}
|
|
|
|
class MatchContext
|
|
|
|
<<Interface>> MatchContext
|
|
|
|
class Router~T~ {
|
|
|
|
+match(path: string, ctx: MatchContext): T
|
|
|
|
}
|
|
|
|
<<Interface>> Router
|
|
|
|
Router <|.. TreeRouter
|
|
|
|
class TreeRouter~T~ {
|
|
|
|
-staticNode: Record<string, TreeRouter<T>>
|
|
|
|
-simpleParamNode?: SimpleParamNode<T>
|
|
|
|
-regexParamNodes: Map<string, RegexNode<T>>
|
|
|
|
-fallbackNode?: Router<T>
|
|
|
|
-elem?: T
|
|
|
|
-findRouter(path: string, ctx: MatchContext): [TreeRouter<T>, string]
|
|
|
|
+match(path: string, ctx?: MatchContext): T
|
|
|
|
+register(path: string, elem: T): this
|
|
|
|
+registerRouter(path: string, router: Router<T>): void
|
|
|
|
-setOrMerge(elem: RegElem<T>): void
|
|
|
|
-registerPath(path: string, elem: RegElem<T>): void
|
|
|
|
-singleRoute(p: string): SingleRouteOutput<T>
|
|
|
|
}
|
|
|
|
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
|
|
|
|
}
|
|
|
|
<<Interface>> 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<void>
|
|
|
|
+parse(content: unknown[]): void
|
|
|
|
+save(): Promise<void>
|
|
|
|
}
|
|
|
|
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
|
|
|
|
}
|
|
|
|
<<Interface>> 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<string, Participant>
|
|
|
|
+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<Participant>
|
|
|
|
+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<ActiveDocumentObject>
|
|
|
|
+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
|
|
|
|
}
|
|
|
|
<<interface>> IFsWatcher
|
|
|
|
class FsWatcherImpl{
|
|
|
|
onNotify(e: FileWatchEvent): void
|
|
|
|
}
|
|
|
|
|
|
|
|
```
|
|
|
|
### 5.1.2 Client Side UML
|
|
|
|
|
|
|
|
```mermaid
|
2022-05-15 01:40:36 +09:00
|
|
|
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
|
|
|
|
}
|
|
|
|
<<Interface>> ViewModelBase
|
|
|
|
ViewModelBase <|.. IViewModel
|
|
|
|
class IViewModel {
|
|
|
|
+pageView: IPageViewModel
|
|
|
|
}
|
|
|
|
<<Interface>> 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
|
|
|
|
}
|
|
|
|
<<Interface>> IPageViewModel
|
|
|
|
IPageViewModel <|.. IDocumentViewModel
|
|
|
|
class IDocumentViewModel {
|
|
|
|
+updateOnNotification(notification: ChunkNotification): void
|
|
|
|
}
|
|
|
|
<<Interface>> 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
|
|
|
|
}
|
|
|
|
<<Interface>> ChunkListMutator
|
|
|
|
class ChunkListState {
|
|
|
|
+chunks: Chunk[]
|
|
|
|
+cloen(): ChunkListState
|
|
|
|
}
|
|
|
|
class ChunkListStateMutator
|
|
|
|
<<Interface>> 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
|
|
|
|
}
|
|
|
|
<<Interface>> 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<void>]
|
|
|
|
+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
|
|
|
|
<<Interface>> 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<RPCResponse>
|
|
|
|
}
|
|
|
|
<<Interface>> IRPCMessageManager
|
|
|
|
IRPCMessageManager <|.. RPCMessageManager
|
|
|
|
class RPCMessageManager {
|
|
|
|
-callbackList: Map<number, RPCCallback>
|
|
|
|
-curId: number
|
|
|
|
-ws?: WebSocket | undefined
|
|
|
|
+opened: boolean
|
|
|
|
+open(url: string | URL, protocals?: string | undefined): Promise<void>
|
|
|
|
+close(): void
|
|
|
|
-genId(): number
|
|
|
|
+genHeader(): Inline
|
|
|
|
+send(message: RPCMethod): Promise<RPCResponse>
|
|
|
|
+sendNotification(message: ChunkNotification): void
|
|
|
|
+addEventListener(type: "notification", callback: RPCMessageMessagerEventListener): void
|
|
|
|
+removeEventListener(type: "notification", callback: RPCMessageMessagerEventListener): void
|
|
|
|
+invokeMethod(m: RPCMessageBody): Promise<RPCResponse>
|
|
|
|
}
|
|
|
|
class FsDirEntry {
|
|
|
|
+name: string
|
|
|
|
+isDirectory: boolean
|
|
|
|
+isFile: boolean
|
|
|
|
+isSymlink: boolean
|
|
|
|
}
|
|
|
|
<<Interface>> FsDirEntry
|
|
|
|
class FsStatInfo {
|
|
|
|
+isFile: boolean
|
|
|
|
+isDirectory: boolean
|
|
|
|
+isSymlink: boolean
|
|
|
|
+size: number
|
|
|
|
+mtime: Date | null
|
|
|
|
+atime: Date | null
|
|
|
|
+birthtime: Date | null
|
|
|
|
}
|
|
|
|
<<Interface>> FsStatInfo
|
|
|
|
FsStatInfo <|.. FsGetResult
|
|
|
|
class FsGetResult {
|
|
|
|
+entries?: FsDirEntry[] | undefined
|
|
|
|
}
|
|
|
|
<<Interface>> FsGetResult
|
|
|
|
class IFsEventMap {
|
|
|
|
+modify: (this: IFsManager, event: MessageEvent<NotImplemented>) => void
|
|
|
|
+create: (this: IFsManager, event: MessageEvent<NotImplemented>) => void
|
|
|
|
+delete: (this: IFsManager, event: MessageEvent<NotImplemented>) => void
|
|
|
|
}
|
|
|
|
<<Interface>> IFsEventMap
|
|
|
|
class IFsManager {
|
|
|
|
+get(path: string): Promise<Response>
|
|
|
|
+getStat(path: string): Promise<FsGetResult>
|
|
|
|
+upload(filePath: string, data: BodyInit): Promise<number>
|
|
|
|
+delete(filePath: string): Promise<number>
|
|
|
|
+mkdir(path: string): Promise<number>
|
|
|
|
+addEventListener(name: string, listener: EventListenerOrEventListenerObject): void
|
|
|
|
+removeEventListener(name: string, listener: EventListenerOrEventListenerObject): void
|
|
|
|
+dispatchEvent(event: Event): boolean
|
|
|
|
}
|
|
|
|
<<Interface>> IFsManager
|
|
|
|
IFsManager <|.. FsManager
|
|
|
|
class FsManager {
|
|
|
|
-manager: RPCMessageManager
|
|
|
|
-prefix: string
|
|
|
|
+get(path: string): Promise<Response>
|
|
|
|
+getStat(filePath: string): Promise<FsGetResult>
|
|
|
|
+upload(filePath: string, data: BodyInit): Promise<number>
|
|
|
|
+mkdir(filePath: string): Promise<number>
|
|
|
|
+delete(filePath: string): Promise<number>
|
|
|
|
}
|
|
|
|
FsGetResult ..> "*" FsDirEntry
|
|
|
|
IFsEventMap ..> "1" IFsManager
|
|
|
|
IFsManager ..> "1" FsGetResult
|
|
|
|
FsManager ..> "1" RPCMessageManager
|
|
|
|
FsManager ..> "1" FsGetResult
|
2022-05-14 14:30:58 +09:00
|
|
|
```
|
|
|
|
|
|
|
|
## 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 작업를 처리하는 함수에 도착합니다.
|
|
|
|
청크 작업을 예로 들겠습니다. 청크 작업에서는 권한을 확인하고
|
2022-05-14 21:49:01 +09:00
|
|
|
명령의 충돌을 히스토리를 비교하며 다시 적용하며 해결합니다.
|
2022-05-14 14:30:58 +09:00
|
|
|
그리고 요구된 작업을 처리하고 문서의 `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
|
|
|
|
}
|
|
|
|
```
|
|
|
|
|
|
|
|
문서 작업도 마찬가지로 이루어집니다.
|
|
|
|
|
2022-05-14 21:49:01 +09:00
|
|
|
### 클라이언트의 메세지 처리 동기화
|
2022-05-14 14:30:58 +09:00
|
|
|
|
|
|
|
클라이언트에서는 다음과 같은 일이 일어납니다.
|
|
|
|
|
2022-05-14 21:49:01 +09:00
|
|
|
먼저 `notification`을 받습니다. 그러면 모든 `DocumentViewModel`에게 이벤트를 전달합니다.
|
|
|
|
그리고 각각의 `DocumentViewModel`은 자기 문서에 일어난 일인지 확인하고 `ChunkListMutator`를 만들어서
|
|
|
|
문서에 적용합니다.
|
2022-05-14 14:30:58 +09:00
|
|
|
```
|
2022-05-14 21:49:01 +09:00
|
|
|
Input: e RPCNotification
|
|
|
|
Input: this document view model
|
2022-05-14 14:30:58 +09:00
|
|
|
|
2022-05-14 21:49:01 +09:00
|
|
|
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();
|
|
|
|
}
|
|
|
|
}
|
2022-05-14 14:30:58 +09:00
|
|
|
```
|
|
|
|
|
|
|
|
### 다른 작업들
|
|
|
|
|
|
|
|
```
|
|
|
|
module chunk {
|
|
|
|
type mode = Read | Write
|
2022-05-15 15:51:45 +09:00
|
|
|
|
2022-05-14 14:30:58 +09:00
|
|
|
struct Chunk {
|
|
|
|
id: string
|
|
|
|
content: string
|
|
|
|
type: string
|
|
|
|
}
|
2022-05-15 15:51:45 +09:00
|
|
|
|
2022-05-14 14:30:58 +09:00
|
|
|
newChunk() {
|
|
|
|
{ id = uuid()
|
|
|
|
; content = ""
|
|
|
|
; type = ""
|
|
|
|
}
|
|
|
|
}
|
2022-05-15 15:51:45 +09:00
|
|
|
|
|
|
|
chunkViewer(chunk : Chunk, focusedChunk : State<string>, deleteThis : () => void) : Component {
|
2022-05-14 14:30:58 +09:00
|
|
|
var mode = Read
|
2022-05-15 15:51:45 +09:00
|
|
|
|
2022-05-14 14:30:58 +09:00
|
|
|
var c = new Component(
|
|
|
|
content { value = chunk.content }
|
|
|
|
settypebutton
|
|
|
|
editbutton
|
|
|
|
deletebutton
|
|
|
|
)
|
2022-05-15 15:51:45 +09:00
|
|
|
|
2022-05-14 14:30:58 +09:00
|
|
|
when mode becomes Read { chunk.content = content }
|
2022-05-15 15:51:45 +09:00
|
|
|
when mode becomes Write { focusedChunk = chunk.id }
|
|
|
|
when focusedChunk is changed { mode = Read }
|
|
|
|
|
2022-05-14 14:30:58 +09:00
|
|
|
when editbutton is clicked { mode = (mode = Read) ? Write : Read }
|
|
|
|
when deletebutton is clicked { deleteThis() }
|
|
|
|
when settypebutton is clicked { chunk.type = prompt() }
|
2022-05-15 15:51:45 +09:00
|
|
|
|
2022-05-14 14:30:58 +09:00
|
|
|
return c
|
|
|
|
}
|
|
|
|
}
|
2022-05-15 15:51:45 +09:00
|
|
|
```
|
|
|
|
|
|
|
|
```
|
|
|
|
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() }
|
|
|
|
}
|
|
|
|
```
|
2022-05-14 14:30:58 +09:00
|
|
|
|
2022-05-15 15:51:45 +09:00
|
|
|
```
|
2022-05-14 14:30:58 +09:00
|
|
|
module document {
|
|
|
|
struct Document {
|
|
|
|
title: string
|
|
|
|
path: Path
|
|
|
|
tags: string set
|
|
|
|
chunks: chunk.Chunk array
|
|
|
|
}
|
2022-05-15 15:51:45 +09:00
|
|
|
|
2022-05-14 14:30:58 +09:00
|
|
|
documentViewer(doc: document) : Component {
|
|
|
|
var focusedChunk = null
|
2022-05-15 15:51:45 +09:00
|
|
|
|
2022-05-14 14:30:58 +09:00
|
|
|
var c = new Component(
|
|
|
|
taglist { value: tags }
|
|
|
|
chunklist
|
|
|
|
)
|
2022-05-15 15:51:45 +09:00
|
|
|
|
2022-05-14 14:30:58 +09:00
|
|
|
delete(id) {
|
|
|
|
i = doc.chunks.find((c) => c.id = id)
|
|
|
|
doc.chunks.remove(i)
|
|
|
|
}
|
2022-05-15 15:51:45 +09:00
|
|
|
|
2022-05-14 14:30:58 +09:00
|
|
|
chunklist = doc.chunks.concat_map((c, i) =>
|
2022-05-15 15:51:45 +09:00
|
|
|
[ divider(i), chuknViewer(c, focusedChunk, () => delete(c.id)) ])
|
|
|
|
|
2022-05-14 14:30:58 +09:00
|
|
|
when divider(i) clicked { doc.chunks.insert(i, c) }
|
|
|
|
when chunkViewer(c) is dropped on divider(i) { doc.chunks.move(c, i) }
|
2022-05-15 15:51:45 +09:00
|
|
|
|
|
|
|
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
|
2022-05-14 14:30:58 +09:00
|
|
|
}
|
|
|
|
}
|
2022-05-15 15:51:45 +09:00
|
|
|
```
|
|
|
|
|
|
|
|
```
|
|
|
|
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
|
|
|
|
}
|
|
|
|
}
|
|
|
|
```
|