SRS/tools/template/architecture.md

19 KiB

5. 설계

5.1 UML

5.1.1 Server Side UML

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
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
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
}
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
classDiagram
class IFsWatcher{
    addEventListener(): void
    onNofity(e: FileWatchEvent): void
}
<<interface>> IFsWatcher
class FsWatcherImpl{
    onNotify(e: FileWatchEvent): void
}

5.1.2 Client Side UML

classDiagram
Error <|-- RPCErrorWrapper
class RPCErrorWrapper {
    +data?: unknown
    +code: RPCErrorCode
    +toJSON(): Inline
}
MessageEvent <|-- RPCNotificationEvent
class RPCNotificationEvent {
    +notification: ChunkNotification
}
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
classDiagram
class ChunkListStateMutator
<<Interface>> ChunkListStateMutator
class ChunkListStateAddMutator
class ChunkListStateDeleteMutator
class ChunkListStateModifyMutator
class ChunkListStateMoveMutator
ChunkListStateMutator <|.. ChunkListStateAddMutator
ChunkListStateMutator <|.. ChunkListStateDeleteMutator
ChunkListStateMutator <|.. ChunkListStateModifyMutator
ChunkListStateMutator <|.. ChunkListStateMoveMutator
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

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<string>, 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

} }