Reflex (작성 예정)

Posted on October 29, 2023

Reflex 공식 사이트
Reflex 공식 문서

앱 만들기를 빠르게 시작하려면 Obelisk를 쓰든가, reflex-platform을 씁니다.

@TODO: Obelisk와 reflex-platform의 역할

의외로 Obelisk, reflex-platform, Reflex-DOM 안 쓰는 순수 Reflex 예시를 찾기 어려웠습니다. 아래는 Reflex만 쓰는 예시긴 한데, 실행하기 위한 Reflex 환경을 잡는데, reflex-platform의 ./try-reflex를 실행하는 게 편합니다. 환경이 준비된 쉘로 들어가서 runghc only-reflex.hs로 실행하면 됩니다.

-- only-reflex.hs
{-# LANGUAGE RankNTypes #-}
module Main where

import Reflex
import Reflex.Host.Class (newEventWithTriggerRef, runHostFrame, fireEvents)
import Control.Concurrent (forkIO)
import Control.Monad (forever)
import Control.Monad.Fix (MonadFix)
import Control.Monad.Identity (Identity(..))
import Control.Monad.IO.Class (liftIO)
import Data.IORef (readIORef)
import Data.Dependent.Sum (DSum ((:=>)))
import System.IO (hSetEcho, hSetBuffering, stdin, BufferMode (NoBuffering))

type TypingApp t m = (Reflex t, MonadHold t m, MonadFix m)
                  => Event t Char
                  -> m (Behavior t String)

host :: (forall t m. TypingApp t m) -> IO ()
host myGuest =
  runSpiderHost $ do
    (e, eTriggerRef) <- newEventWithTriggerRef
    b <- runHostFrame $ myGuest e
    forever $ do
      input <- liftIO getChar
      liftIO $ putStrLn $ "Input Event: " ++ show input
      mETrigger <- liftIO $ readIORef eTriggerRef
      case mETrigger of
        Nothing -> return ()
        Just eTrigger -> fireEvents [eTrigger :=> Identity input]
      output <- runHostFrame $ sample b
      liftIO $ putStrLn $ "Output Behavior: " ++ show output

guest :: TypingApp t m
guest e = do
  d <- foldDyn (:) [] e
  return $ fmap reverse $ current d

main :: IO ()
main = do
  putStrLn "Welcome to the example Reflex host app; press Ctrl+C to exit"
  putStrLn "Press any key to process it with the Reflex FRP engine"
  hSetEcho stdin False
  hSetBuffering stdin NoBuffering
  host guest

Obelisk

obsidiansystems/obelisk
고퀄의 웹, 모바일 앱을 Reflex를 써서 매우 빠르게 만들 수 있다고 합니다. 아직은 제대로 안 써봐서 얼마나 빠르게 만들 수 있을까 싶습니다. 같은 하스켈 코드로 Web, iOS, Android, MacOS, Linux에서 돌아가게 만들 수 있다고 하니, 일단 눈이 갑니다.

명령행에서 쓰는 CLI툴입니다.

Reflex Platform

Reflex Platform

Reflex를 쓰는 하스켈 패키지를 다양한 플래폼에서 돌아가게 빌드할 수 있는 엄선된curated 패키지 세트 및 도구 입니다. 닉스 패키지 매니저로 돌릴 닉스 표현식 모음과 ghc, ghcjs를 쓰기 위한 스크립트 모음이라 보면 됩니다.

  1. 엄선된 패키지와 도구: Reflex Platform의 핵심 패키지는, 서로 함께 돌리는 테스트를 마쳤습니다.
  2. 바이너리로 받을 수 있어(public cache가 있습니다.), 소스에서부터 빌드할 필요가 없습니다.
  3. 하스켈 생태계 바깥쪽의 요소(C라이브러리 같은 것들)까지도 닉스가 의존성을 잠궈놨습니다.
  4. 크로스 플래폼. 모바일(iOS, Android) 웹(Javascript), 데스크탑(리눅스, 맥OS)에서 돌아갑니다.
  5. 쉽게 개발하도록 도와주는 도구들을 포함하고 있습니다. (hoogle 서버도 포함되어 있어 로컬에서 돌릴 수 있습니다.)

GHCJS로 컴파일하면, 메모리를 엄청 잡아 먹습니다. 최소 8GB, 권장 16GB라 합니다.
TMPDIR 디렉토리도, /nix/store도 꽤 잡아 먹습니다.

공부용이나, 작은 프로젝트는 try-reflex 스크립트를 돌려서 환경을 잡으면 되는데, 큰 프로젝트는 cabal을 쓰는 게 좋다 합니다. cabal로 환경 잡기

try-reflex를 실행하는데, 세 시간이 넘게 걸리고, 디스크 용량은 거의 30GB를 먹었습니다. 환경마다 다르겠지만, 엔간한 장비에선 긴 시간이 필요한 작업 같습니다.

실행 후 한참을 기다렸는데, 믿었던 닉스가 No space left on device 오류를 뱉습니다. 설치할 곳의 공간은 여유가 있는데도 계속 오류가 나서, 찾아 보니 닉스가 쓰는 임시 폴더 용량이 문제였습니다.
export TEMP=/other/sufficientDir 혹은
export TMPDIR=/other/sufficientDir을 했는데도
임시 디렉토리를 사용하는 곳이 바뀌지 않았습니다. 닉스가 딱히 시스템 수준의 환경 변수를 읽어가지 않나 봅니다. 시스템 수준의 환경 변수가 아닌 데몬을 위한 환경 변수를 잡아주어 성공했습니다. 데비안6.1.66-1 (2023-12-09) 환경.

sudo vim /etc/systemd/system/nix-daemon.service

[Service]
...
Environment=TMPDIR=/other/sufficientDir

위와 같이 추가하고

sudo systemctl daemon-reload
sudo service nix-daemon restart

※ NTFS 파티션에 잡으니 cp: perserving times for …: Operation not permitted 오류가 납니다.

Reflex Basics

Reflex Basics

reflex
콜백 스타일을 쓰지 않고, side effect도 없이 인터랙티브 프로그램을 만듭니다. 조합compositable 가능한 이벤트시간에 따라 변하는 값을 써서 인터랙티브 시스템을 순수 함수로 표현합니다. DOM 생성 코드와는 무관합니다. reflex는 reflex-dom의 기반이긴 하지만, 꼭 웹 관련 앱이 아니더라도 FRP 아키텍처로 구현할 때 쓸 수 있습니다.

reflex-dom-core와 reflex-dom
DOM 위젯, 웹 소켓, XHR 요청을 만들기 위한 API를 제공합니다.

이론에 대한 설명은 딱히 같이 하지 않아, Reflex만 보는 분들은 다른 쪽(FRAN, Yampa, reactive-banana, …) 문서들을 참고해야겠습니다.

Behavior
시간에 따라 변하는 값을 위한 컨테이너. 샘플링할 수는 있지만, 자신이 변한다고 외부에 알릴 방법은 없습니다.

Behavior t a

모든 시간에 값이 존재해야 하는데, 만일 그렇지 않은 값을 Behavior로 모델링 한다면 Behavior t (Maybe a)를 써야 합니다. Behavior 값들은 tagattach 함수를 써서 Event로 태깅할 수 있습니다. 처음 생성할 때 sample 함수를 쓰면 위젯 안에서 샘플링될 수 있습니다.

※ 태깅tagging - 이벤트가 발생하는 순간 Behavior의 값을 이벤트에 넣는 것.

Event

Event t a

예시) 버튼 클릭 Event t (), 키 누름 Event t Char

실 구현말고, 의미적으론 [(t, a)]로 생각할 수 있습니다.

다른 프레임워크에서 이벤트 네트워크라 부르는 걸, 여기선 이벤트-전파-그래프라 부릅니다. 외부 값(외부 이벤트)을 이벤트-전파-그래프에 넣을 때는 newTriggerEvent IO 액션을 씁니다.

newTriggerEvent :: TriggerEvent t m => m (Event t a, a -> IO ())

Dynamic
Event와 Behavior를 튜플에 담아 둔 것

DOM API는 기본적으로 push-기반입니다. 그래서 Behavior를 쓸 곳에 Dynamic을 쓰는 경우가 많습니다.

Dynamic값에 updated를 적용해서, 값이 변했을 때 이벤트를 얻을 수 있습니다.

updated :: (Reflex t) => Dynamic t a -> Event t a

타임라인 t는 FRP 컨텍스트와 별 개입니다. 단일 프로그램이 Reflex를 여러 컨텍스트에서 돌릴 경우 꼬이지 않게 해줍니다.?

Reflex-DOM 앱의 아키텍처

위젯과 위젯들을 연결하는 접착glue 코드로 구성됩니다. 위젯은 Event나 Dynamic value에 따라 컨텐츠를 수정할 수 있는 능력을 가진 DOM 구조의 무언가로 볼 수 있습니다. 이벤트를 발생시키는 input field 같은 구조들도 있습니다. 위젯들은 마우스 클릭같은 사용자 인터랙션에 반응할 수 있습니다. (이 걸 위젯이 마우스 클릭을 캡처한다고 말하기도 합니다.)

DOM 생성

“오브젝트” 트리
모나드 DomBuilder로 DOM 생성 작업을 합니다.

ghcjs
ghc같은 컴파일러

ghcjs-dom
DOM과 웹API와 쓰일 인터페이스 API를 제공하는 라이브러리

Reflex-Dom 예시를 보여주는 여러 페이지가 있지만, 아래 문서가 가장 보기 편했습니다. Html에 있는 엘리먼트와 대응되는 코드들을 하나 하나 예시를 들어 친절하게 설명합니다.
A Beginner-friendly Step by Step Tutorial for Reflex-Dom

Webkit

KDE의 KHTML과 KJS에 파생된 웹 컨텐트 엔진. Apple 사파리 브라우저에 쓰입니다. HTML, SVG, XML 등을 디스플레이할 때 쓰입니다. DOM, XMLHttpRequest, XSLT, CSS, Javascript/ECMAscript 등을 지원합니다.

※ 데이터 흐름 아이디어만 보기 위한 모형 코드

어디 있는 코드를 옮긴 게 아니고, 직접 작성한 코드며, 문제가 많은 코드입니다. 오직 Event, Behavior 흐름만 보기 위한 모형 코드입니다. Reflex 실제 구현과는 전혀 무관하게, 아주 간단하게 (실용으로 쓸 수 없는 방식) 루프돌며 폴링하는 것으로 구현해서 데이터 흐름 아이디어만 봤습니다. FRP의 Event, Behavior 조합Combination 능력도 빠져 있으니, FRP 라기 보단, F빠진 RP 아이디어만 봤다고 해야겠습니다.

각 독립된 루프 하나 하나가 봉화처럼 느껴집니다. 봉화를 바라보는 봉화를 두어 데이터가 흘러갑니다. 실제 동작과 좀 더 유사한 비유를 하자면, 플래시를 깜빡여서 신호를 보내면, 다른 곳에서 받아 깃발을 바꿔 놓고, 플래시를 또 깜빡입니다. 깜빡이는 플래시는 순간적으로만 존재하지만, 깃발은 언제든 필요하면 현재 상태를 알 수 있습니다. 플래시가 Event이고 깃발이 Dynamic 입니다.

사이드 이펙트로 꽉 채운 함수형스럽지 못한 코드지만, 데이터 흐름은 잘 보이는 것 같아, 일단 버리지 않고 올려 놓습니다.

-- MiniReflex.hs
{-# LANGUAGE GeneralizedNewtypeDeriving #-}

module MiniReflex where

import Control.Concurrent (threadDelay, forkIO)
import Control.Monad (forever, void)
import Data.IORef
import Control.Monad.IO.Class (MonadIO(..))
import Control.Monad (when)
import Control.Monad.Fix
import System.IO.Unsafe
import Debug.Trace

-- 이벤트가 발생하면 Just a, 아니면 Nothing
newtype Event a = Event { unEvent :: IORef (Maybe a) } 

instance Functor Event where
  -- 이벤트가 들고 있는 값을 변형하는 역할을 펑터로 표현한다.
  -- 실용 코드에선 unsafePerformIO를 함부로 쓰면 안된다.
  fmap f (Event ref) = Event $ unsafePerformIO $ do
    val <- readIORef ref 
    case val of
      Just v -> do
        traceM ("Just")
        newIORef (Just (f v))
      Nothing -> do
        newIORef Nothing

-- 실제 Dynamic은 변경될 때 이벤트를 발생시킬 수 있는데, 
-- updated에서 이벤트를 만들어 내는 것으로 대체했다.
newtype Dynamic a = Dynamic { unDynamic :: IORef a }

newtype AppM a = AppM { unAppM :: IO a }
  deriving (Functor, Applicative, Monad, MonadIO, MonadFix)

-- 이벤트를 Ref로 모델링
newEvent :: AppM (Event a, a -> IO ())
newEvent = liftIO $ do
  ref <- newIORef Nothing
  let fireFunc = \val -> writeIORef ref (Just val)
  return (Event ref, fireFunc) -- (이벤트, 트리거)

-- Dynamic을 Ref로 모델링
-- Event도 Dynamic도 Ref로 모델링하다 보니 헛갈리는데,
-- Event가 발생하면 Dynamic이 가진 Ref에 값을 넣어서
-- 이 Ref를 보고 있는 루프가 Just 값을 얻어가는 식이다.
holdDyn :: (Eq a, Show a) => a -> Event a -> AppM (Dynamic a)
holdDyn initialVal (Event evRef) = liftIO $ do
  -- 이벤트가 발생하면, 이벤트가 가진 값을 dynRef에 기억 
  dynRef <- newIORef initialVal
  void $ forkIO $ forever $ do
    eventVal <- readIORef evRef
    case eventVal of
      Just val -> do -- 이벤트가 발생했다면 Just가 들어 있다.
        oldDynVal <- readIORef dynRef
        when (val /= oldDynVal) $ writeIORef dynRef val
        writeIORef evRef Nothing -- 이벤트를 한 번 소비하면 초기화
      Nothing -> threadDelay 10000 -- 10000 밀리초마다 폴링
  return (Dynamic dynRef)

updated :: (Eq a, Show a) => Dynamic a -> AppM (Event a)
updated (Dynamic dynRef) = liftIO $ do
  evRef <- newIORef Nothing -- Dynamic이 변할 때 자동으로 fire되는 이벤트라
                            -- 따로 트리거가 없는 이벤트로 생각하면 된다.
  lastValRef <- newIORef =<< readIORef dynRef
  void $ forkIO $ do
    forever $ do
      lastVal <- readIORef lastValRef
      currentVal <- readIORef dynRef
      if currentVal /= lastVal 
        then do -- 값이 바뀌면 알리기 위해 Just 값 넣기
          writeIORef evRef (Just currentVal)
          writeIORef lastValRef currentVal
        else do
          writeIORef evRef Nothing -- 아무일도 없다는 뜻으로 Nothing
      threadDelay 10000 -- 10000 밀리초마다 폴링
  return (Event evRef)

performEvent_ :: Event Int -> AppM ()
performEvent_ (Event evRef) = liftIO $ void $ forkIO $ forever $ do
  evCount <- readIORef evRef
  let ioAction = (\n -> putStrLn $ "Current count: " ++ show n) <$> evCount
  -- Event tick 을 Event (IO a)로 변형한다.
  case ioAction of
    Just action -> do
      action
      writeIORef evRef Nothing -- 이벤트 소비
    Nothing -> do
      threadDelay 1000

runMiniReflex :: AppM () -> IO ()
runMiniReflex app = do
  unAppM app

위 프레임워크를 사용해서 틱 이벤트가 발생할 때마다 카운트를 출력하고, Enter키를 입력하면 끝내는 동작으로 테스트했습니다.

-- Main.hs
import MiniReflex
import Control.Concurrent (forkIO, threadDelay)
import Control.Monad (forever, void)
import Control.Monad.IO.Class (liftIO)
import Data.IORef
import System.IO

main :: IO ()
main = runMiniReflex $ do
  liftIO $ hSetBuffering stdout NoBuffering
  (tickEvent, fireTick) <- newEvent
  dynCount <- holdDyn (0 :: Int) tickEvent
  evCountChanged <- updated dynCount
  performEvent_ evCountChanged

  liftIO $ putStrLn "--- Mini Reflex Counter Started ---"
  liftIO $ putStrLn "Press Enter to stop."

  void $ liftIO $ forkIO $ do
    countRef <- newIORef (0 :: Int)
    forever $ do
      threadDelay 1000000
      currentCount <- readIORef countRef
      let nextCount = currentCount + 1
      writeIORef countRef nextCount
      fireTick nextCount
  liftIO $ do
    _ <- getLine -- enter가 들어올 때까지 블록
    putStrLn "--- Mini reflex Counter Stopped ---"

이벤트 구독 모델

아마도, 사용자에게 프레임워크 API로 노출하진 않지만, 아래와 같이 외부 이벤트와 FRP의 이벤트, Behavior 네트워크를 연결하는 Push 모양이 어딘가에는 있을 거라 생각합니다.

data Event a = Event { subscribe :: (a -> IO ()) -> IO () } 
-- 이벤트가 발생하면 실행할 함수들을 받아 모아 둔다.

data Behavior a = Behavior (IO a)

-- 이벤트 구독에서 자주 보던, OOP에서 객체 하나 만드는 것과 비슷하다.
newEvent :: IO (Event a, a -> IO ())
newEvent = do
  subscribersRef <- newIORef []
  let event = Event $ \callback -> modifyIORef' subscribersRef (callback :) 
      trigger val = readIORef subscribersRef >>= mapM_ (\cb -> cb val)
Github 계정이 없는 분은 메일로 보내주세요. lionhairdino at gmail.com