앱 만들기를 빠르게 시작하려면 Obelisk를 쓰든가, reflex-platform을 씁니다.
@TODO: Obelisk와 reflex-platform의 역할
의외로 Obelisk, reflex-platform, Reflex-DOM 안 쓰는 순수 Reflex 예시를 찾기 어려웠습니다. 아래는 Reflex만 쓰는 예시긴 한데, 실행하기 위한 Reflex 환경을 잡는데, reflex-platform의 ./try-reflex
를 실행하는 게 편합니다. 환경이 준비된 쉘로 들어가서 runghc only-reflex.hs
로 실행하면 됩니다.
newEventWithTriggerRef
: 이벤트를 만들고, 트리거를 Ref에 저정합니다.runHostFrame
: 이벤트와 Behavior를 엮어 만들어 둔 코드 덩어리를 한 번 실행하는 걸 Frame이라 부릅니다. 시작점이 되는 이벤트가 발생하면, 이 Frame이 한 번 실행되는데, 이벤트 없이 강제로 시작할 때 씁니다. (하스켈에서 run
접두어가 붙은 것들은, 준비해 두었던 코드 덩어리가 현실과 연결되는 것처럼 보면 편할 때가 많습니다.)sample
: Behavior가 가진 값을 가져옵니다.fireEvents
: 이벤트를 발생시킵니다.foldDyn
: fireEvents
가 이벤트를 발생시키면, foldDyn
같은 것들이 반응합니다.-- 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 $ do
runSpiderHost <- newEventWithTriggerRef
(e, eTriggerRef) <- runHostFrame $ myGuest e
b $ do
forever <- liftIO getChar
input $ putStrLn $ "Input Event: " ++ show input
liftIO <- liftIO $ readIORef eTriggerRef
mETrigger case mETrigger of
Nothing -> return ()
Just eTrigger -> fireEvents [eTrigger :=> Identity input]
<- runHostFrame $ sample b
output $ putStrLn $ "Output Behavior: " ++ show output
liftIO
guest :: TypingApp t m
= do
guest e <- foldDyn (:) [] e
d return $ fmap reverse $ current d
main :: IO ()
= do
main 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"
False
hSetEcho stdin NoBuffering
hSetBuffering stdin host guest
obsidiansystems/obelisk
고퀄의 웹, 모바일 앱을 Reflex를 써서 매우 빠르게 만들 수 있다고 합니다. 아직은 제대로 안 써봐서 얼마나 빠르게 만들 수 있을까 싶습니다. 같은 하스켈 코드로 Web, iOS, Android, MacOS, Linux에서 돌아가게 만들 수 있다고 하니, 일단 눈이 갑니다.
명령행에서 쓰는 CLI툴입니다.
reflex
를 써서 프론트 엔드 뼈대를 만듭니다.snap
을 써서 백엔드 뼈대를 만듭니다.ob run
파일에 저장하면 자동으로 앱을 리빌드 합니다. jsaddle-warp를 쓰는 프론트엔드를 제공합니다.ob repl
ghci 쉘ob deploy
EC2에 배포하는 걸 도와줍니다. 최적화하고 짧게 만든 js를 생성합니다.obelisk-route
Reflex를 쓰는 하스켈 패키지를 다양한 플래폼에서 돌아가게 빌드할 수 있는 엄선된curated 패키지 세트 및 도구 입니다. 닉스 패키지 매니저로 돌릴 닉스 표현식 모음과 ghc, ghcjs를 쓰기 위한 스크립트 모음이라 보면 됩니다.
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
콜백 스타일을 쓰지 않고, 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 값들은 tag
나 attach
함수를 써서 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를 여러 컨텍스트에서 돌릴 경우 꼬이지 않게 해줍니다.?
위젯과 위젯들을 연결하는 접착glue 코드로 구성됩니다. 위젯은 Event나 Dynamic value에 따라 컨텐츠를 수정할 수 있는 능력을 가진 DOM 구조의 무언가로 볼 수 있습니다. 이벤트를 발생시키는 input field 같은 구조들도 있습니다. 위젯들은 마우스 클릭같은 사용자 인터랙션에 반응할 수 있습니다. (이 걸 위젯이 마우스 클릭을 캡처한다고 말하기도 합니다.)
“오브젝트” 트리
모나드 DomBuilder
로 DOM 생성 작업을 합니다.
ghcjs
ghc같은 컴파일러
ghcjs-dom
DOM과 웹API와 쓰일 인터페이스 API를 제공하는 라이브러리
Reflex-Dom 예시를 보여주는 여러 페이지가 있지만, 아래 문서가 가장 보기 편했습니다. Html에 있는 엘리먼트와 대응되는 코드들을 하나 하나 예시를 들어 친절하게 설명합니다.
A Beginner-friendly Step by Step Tutorial for Reflex-Dom
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
<- readIORef ref
val case val of
Just v -> do
"Just")
traceM (Just (f v))
newIORef (Nothing -> do
Nothing
newIORef
-- 실제 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 ())
= liftIO $ do
newEvent <- newIORef Nothing
ref 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)
Event evRef) = liftIO $ do
holdDyn initialVal (-- 이벤트가 발생하면, 이벤트가 가진 값을 dynRef에 기억
<- newIORef initialVal
dynRef $ forkIO $ forever $ do
void <- readIORef evRef
eventVal case eventVal of
Just val -> do -- 이벤트가 발생했다면 Just가 들어 있다.
<- readIORef dynRef
oldDynVal /= oldDynVal) $ writeIORef dynRef val
when (val Nothing -- 이벤트를 한 번 소비하면 초기화
writeIORef evRef Nothing -> threadDelay 10000 -- 10000 밀리초마다 폴링
return (Dynamic dynRef)
updated :: (Eq a, Show a) => Dynamic a -> AppM (Event a)
Dynamic dynRef) = liftIO $ do
updated (<- newIORef Nothing -- Dynamic이 변할 때 자동으로 fire되는 이벤트라
evRef -- 따로 트리거가 없는 이벤트로 생각하면 된다.
<- newIORef =<< readIORef dynRef
lastValRef $ forkIO $ do
void $ do
forever <- readIORef lastValRef
lastVal <- readIORef dynRef
currentVal if currentVal /= lastVal
then do -- 값이 바뀌면 알리기 위해 Just 값 넣기
Just currentVal)
writeIORef evRef (
writeIORef lastValRef currentValelse do
Nothing -- 아무일도 없다는 뜻으로 Nothing
writeIORef evRef 10000 -- 10000 밀리초마다 폴링
threadDelay return (Event evRef)
performEvent_ :: Event Int -> AppM ()
Event evRef) = liftIO $ void $ forkIO $ forever $ do
performEvent_ (<- readIORef evRef
evCount let ioAction = (\n -> putStrLn $ "Current count: " ++ show n) <$> evCount
-- Event tick 을 Event (IO a)로 변형한다.
case ioAction of
Just action -> do
actionNothing -- 이벤트 소비
writeIORef evRef Nothing -> do
1000
threadDelay
runMiniReflex :: AppM () -> IO ()
= do
runMiniReflex app 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 ()
= runMiniReflex $ do
main $ hSetBuffering stdout NoBuffering
liftIO <- newEvent
(tickEvent, fireTick) <- holdDyn (0 :: Int) tickEvent
dynCount <- updated dynCount
evCountChanged
performEvent_ evCountChanged
$ putStrLn "--- Mini Reflex Counter Started ---"
liftIO $ putStrLn "Press Enter to stop."
liftIO
$ liftIO $ forkIO $ do
void <- newIORef (0 :: Int)
countRef $ do
forever 1000000
threadDelay <- readIORef countRef
currentCount let nextCount = currentCount + 1
writeIORef countRef nextCount
fireTick nextCount$ do
liftIO <- 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 ())
= do
newEvent <- newIORef []
subscribersRef let event = Event $ \callback -> modifyIORef' subscribersRef (callback :)
= readIORef subscribersRef >>= mapM_ (\cb -> cb val) trigger val