에코 서비스

여러 Wippy 프로세스, 채널, 코루틴, 메시지 전달, 프로세스 모니터링을 사용하는 CLI 에코 서비스를 만듭니다.

분류: 실행 가능한 튜토리얼. 로컬 단일 노드 CLI 애플리케이션을 위한 완전한 레지스트리와 Lua 소스, 시작 및 검증 단계를 제공합니다.

개요

이 튜토리얼에서는 릴레이 서비스로 메시지를 보내는 CLI 클라이언트를 만듭니다. 릴레이는 각 메시지를 처리할 워커를 생성합니다. 다음 내용을 보여 줍니다.

  • 프로세스 생성 — 자식 프로세스를 동적으로 만듭니다.
  • 메시지 전달 — 보내기와 받기 연산으로 프로세스 간에 통신합니다.
  • 채널과 select — 여러 이벤트 소스를 기다립니다.
  • 코루틴 — 한 프로세스 안에서 동시 작업을 실행합니다.
  • 프로세스 등록 — 이름으로 프로세스를 찾습니다.
  • 모니터링 — 자식 프로세스의 수명 주기를 추적합니다.

사전 요구 사항

  • wippy 명령으로 사용할 수 있는 Wippy 런타임 v0.3.32a. wippy version --short로 확인하세요.

  • 대화형 터미널

  • 빈 작업 디렉터리. 아래 파일을 추가하기 전에 프로젝트와 소스 디렉터리를 만듭니다.

    mkdir echo-service
    cd echo-service
    mkdir src
    

아키텍처

flowchart TB
    subgraph terminal["terminal.host"]
        CLI["CLI Process"]
    end

    subgraph processes["process.host"]
        Relay["Relay Process
(+ stats coroutine)"] W1["Worker 1"] W2["Worker 2"] W3["Worker N"] end CLI -->|"send('relay', 'echo', msg)"| Relay Relay -->|"spawn_monitored(worker)"| W1 Relay -->|"spawn_monitored(...)"| W2 Relay -->|"spawn_monitored(...)"| W3 W1 -->|"send(sender, 'echo_response')"| CLI W2 -->|"send(...)"| CLI W3 -->|"send(...)"| CLI

프로젝트 구조

echo-service/
├── wippy.lock
└── src/
    ├── _index.yaml
    ├── cli.lua
    ├── relay.lua
    └── worker.lua

엔트리 정의

src/_index.yaml을 만듭니다.

version: "1.0"
namespace: app

entries:
  - name: policy
    kind: security.policy
    policy:
      actions:
        - process.send
        - process.spawn
        - process.spawn.monitored
        - process.registry.register
      resources: "*"
      effect: allow

  - name: terminal
    kind: terminal.host
    lifecycle:
      auto_start: true

  - name: processes
    kind: process.host
    lifecycle:
      auto_start: true

  - name: cli
    kind: process.lua
    source: file://cli.lua
    method: main
    modules:
      - io
      - time
    security:
      policies: [app:policy]

  - name: relay
    kind: process.lua
    source: file://relay.lua
    method: main
    modules:
      - logger
      - time
    security:
      policies: [app:policy]

  - name: relay-service
    kind: process.service
    process: app:relay
    host: app:processes
    lifecycle:
      auto_start: true

  - name: worker
    kind: process.lua
    source: file://worker.lua
    method: main
    modules:
      - time
    security:
      policies: [app:policy]

보안은 기본적으로 거부이므로, 각 프로세스는 자신이 수행하는 액션(이름 등록, 메시지 전송, 모니터링되는 워커 스폰)을 부여하는 정책을 지정하는 security: 블록을 가집니다.

릴레이 프로세스

릴레이는 자신을 등록하고, 메시지를 처리하고, 워커를 생성하고, 통계 코루틴을 실행합니다.

src/relay.lua를 만듭니다.

local logger = require("logger")
local time = require("time")

local stats = {
    messages = 0,
    workers_spawned = 0
}

local function stats_reporter()
    while true do
        time.sleep("5s")
        logger:info("stats", {
            messages = stats.messages,
            workers_spawned = stats.workers_spawned
        })
    end
end

local function main()
    local inbox = process.inbox()
    local events = process.events()

    local _, register_err = process.registry.register("relay")
    if register_err then
        error("cannot register relay: " .. tostring(register_err))
    end
    logger:info("relay started", {pid = process.pid()})

    coroutine.spawn(stats_reporter)

    while true do
        local r = channel.select {
            inbox:case_receive(),
            events:case_receive()
        }

        if r.channel == events then
            local event = r.value
            if event.kind == process.event.CANCEL then
                logger:info("relay stopping", stats)
                return
            elseif event.kind == process.event.EXIT then
                logger:info("worker exited", {
                    from = event.from,
                    result = event.result
                })
            end
        else
            local msg = r.value
            if msg:topic() == "echo" then
                local echo = msg:payload():data()
                stats.messages = stats.messages + 1

                local worker_pid, err = process.spawn_monitored(
                    "app:worker",
                    "app:processes",
                    echo.sender,
                    echo.data
                )

                if err then
                    logger:error("spawn failed", {error = tostring(err)})
                else
                    stats.workers_spawned = stats.workers_spawned + 1
                end
            end
        end
    end
end

return { main = main }

핵심 패턴 {id="relay-key-patterns"}

코루틴 생성

coroutine.spawn(stats_reporter)

주 함수와 메모리를 공유하는 코루틴을 시작합니다. 코루틴은 time.sleep 같은 I/O 연산에서 양보합니다.

채널 선택

local r = channel.select {
    inbox:case_receive(),
    events:case_receive()
}

여러 채널에서 대기합니다. r.channel은 어떤 것이 발생했는지 식별하고, r.value는 데이터를 포함합니다. 런타임이 서비스를 종료할 때 CANCEL 이벤트가 동일한 이벤트 채널로 도착합니다. 여기서 main에서 반환하면 호스트가 중지 타임아웃을 기다리는 대신 깔끔하게 중지할 수 있습니다.

페이로드 추출

local echo = msg:payload():data()

메시지의 주제 문자열은 msg:topic()으로, 페이로드는 msg:payload():data()로 가져옵니다.

모니터링과 함께 생성

local worker_pid, err = process.spawn_monitored("app:worker", "app:processes", ...)

워커를 생성하고 모니터링을 시작합니다. 워커가 종료되면 릴레이가 EXIT 이벤트를 받습니다.

워커 프로세스

워커는 인수를 직접 받고 송신자에게 응답을 보냅니다.

src/worker.lua를 만듭니다.

local function main(sender_pid, data)
    local response = {
        data = string.upper(data),
        worker = process.pid()
    }

    local _, send_err = process.send(sender_pid, "echo_response", response)
    if send_err then
        error("cannot send echo response: " .. tostring(send_err))
    end

    return 0
end

return { main = main }

CLI 프로세스

CLI는 등록된 릴레이 이름으로 메시지를 보내고 시간 제한을 적용해 각 응답을 기다립니다.

src/cli.lua를 만듭니다.

local io = require("io")
local time = require("time")

local reset = "\027"
local function dim(s) return "\027" .. s .. reset end
local function green(s) return "\027" .. s .. reset end
local function yellow(s) return "\027" .. s .. reset end
local function cyan(s) return "\027" .. s .. reset end

local function main()
    local inbox = process.inbox()

    -- Wait for relay to register its name
    local deadline = time.after("5s")
    while not process.registry.lookup("relay") do
        local tick = time.after("50ms")
        local r = channel.select { deadline:case_receive(), tick:case_receive() }
        if r.channel == deadline then
            io.print("relay not ready")
            return 1
        end
    end

    io.print(cyan("Echo Client"))
    io.print(dim("Type messages to echo. Ctrl+C to exit.\n"))

    while true do
        local _, write_err = io.write(yellow("> "))
        if write_err then
            io.eprint("cannot write prompt:", write_err)
            return 1
        end

        local _, flush_err = io.flush()
        if flush_err then
            io.eprint("cannot flush prompt:", flush_err)
            return 1
        end

        local input, read_err = io.readline()
        if read_err then
            io.eprint("cannot read input:", read_err)
            return 1
        end

        if not input or #input == 0 then
            break
        end

        local msg = {
            sender = process.pid(),
            data = input
        }
        local _, err = process.send("relay", "echo", msg)
        if err then
            io.print(dim("  error: " .. tostring(err)))
        else
            local timeout = time.after("2s")
            local r = channel.select {
                inbox:case_receive(),
                timeout:case_receive()
            }

            if r.channel == timeout then
                io.print(dim("  timeout"))
            else
                local msg = r.value
                if msg:topic() == "echo_response" then
                    local resp = msg:payload():data()
                    io.print(green("  " .. resp.data))
                    io.print(dim("  from worker: " .. resp.worker))
                end
            end
        end
    end

    io.print("\nGoodbye!")
    return 0
end

return { main = main }

핵심 패턴 {id="cli-key-patterns"}

이름으로 보내기

process.send("relay", "echo", msg)

process.send는 등록된 이름을 대상으로 받을 수 있으며, 해당 이름을 해석할 수 없으면 오류를 반환합니다.

시간 제한 패턴

local timeout = time.after("2s")
local r = channel.select {
    inbox:case_receive(),
    timeout:case_receive()
}
if r.channel == timeout then
    -- timed out
end

실행

wippy init
wippy run -x app:cli

출력 예:

Echo Client
Type messages to echo. Ctrl+C to exit.

> hello world
  HELLO WORLD
  from worker: {c49e0627-fcdf-53ec-a95d-6f84bc3715f3@app:processes|0x00005}

워커 PID는 런타임에 생성되므로 실행마다 다릅니다. 여러 줄을 입력해 각 응답이 대문자인지 확인합니다. 빈 줄을 제출하면 정상적으로 종료됩니다.

문제 해결과 정리

  • relay not ready는 자동 시작된 릴레이가 5초 안에 등록되지 않았다는 뜻입니다. 런타임 로그에서 릴레이 시작, 정책 또는 레지스트리 오류를 확인하세요.
  • not allowed to spawn 또는 not allowed to send는 프로세스 엔트리에 위에서 설명한 app:process-policy 보안 컨텍스트가 없다는 뜻입니다.
  • no terminal host found는 terminal.host 엔트리가 없다는 뜻입니다. 프로젝트에 터미널 호스트가 여러 개라면 실행 명령에 --host app:terminal을 추가하세요.
  • 보낸 뒤 시간 초과가 발생했다면 워커가 응답하지 않은 것입니다. 릴레이 로그에서 생성 실패를 확인하고 app:worker와 app:processes가 엔트리 이름과 일치하는지 확인하세요.
  • 빈 줄을 제출해 CLI를 종료합니다. 런타임이 계속 실행 중이면 Ctrl+C를 누르세요. 디렉터리를 벗어난 뒤 echo-service/가 일회용 연습 프로젝트였다면 삭제합니다.

다음 단계