Create a table with websocket API * observable 은 더 나은 방법으로 실험적인 코드를 작성할 수 있도록 도와 줍니다. 여기에 websocket API 를 활용하면, 실시간으로 업데이트되는 문서를 다양한 방식으로 탐색하고, 테스트하면서 만들 수 있습니다. 아래는 암호화폐 거래소 DAYBIT 의 실시간 Market summary 데이터를 보여주는 테이블 입니다. (업데이트된 테이블의 값에는 애니메이션이 표시됩니다.)
BTC = createTable(data)
ETH = createTable(data, 'EOS')
이런 테이블을 만들기 위해서는 아래와 같은 사항들을 observable notebook 에서 구현할 수 있어야 합니다. Phoenix Channel 을 통해 데이터 가져오기 지속적으로 전달되는 데이터를 다른 notebook cell 에 전파하기 업데이트된 테이블 항목에 애니메이션을 적용하기
Phoenix Channel 을 통해 데이터 가져오기 DAYBIT 은 Phoenix framework (이하 Phoenix) 로 구현된 암호화폐 거래소 입니다. Phoenix 는 websocket 에 기반을 둔 Channel 을 통해 수십, 수백만에 달하는 클라이언트들과 실시간으로 메시지를 주고받을 수 있습니다. 클라이언트는 Channel 을 통해 하나의 websocket 연결 속에서도 데이터를 주제별로 나누어 전달받을 수 있습니다. Phoenix 가 제공하는 phoenix.js 를 사용하면 웹 클라이언트에서 Channel 을 편리하게 사용할 수 있습니다. phoenix.js 는 module 형태로 작성되지 않았기 때문에, 이를 바로 가져다 observable 에 사용할 수 없습니다. (observable 에서 module 을 가져다 쓰는 방법에 대해서는 Introduction to require 에 자세히 나와 있습니다.)
require('https://cdn.rawgit.com/phoenixframework/phoenix/master/assets/js/phoenix.js')
다행스럽게도 phoenix.js 파일을 가져다 약간의 수정을 거쳐 node.js 에서 사용할 수 있게 만들어둔 phoenix-channels 란 프로젝트가 있습니다. 이를 사용하면 node.js 에서도 websocket 을 통해 Phoenix 서버로 부터 데이터를 실시간으로 가져올 수 있습니다. phoenix-channels 프로젝트는 npm 에도 배포되어 있습니다.
require('phoenix-channels')
그래도 phoenix-channels 모듈을 바로 가져올 수 없습니다. phoenix-channels 는 lodash 처럼 한번에 모든 내용을 로드할 수 있는 파일을 제공해 주지 않기 때문입니다. 이런 상황은 능숙한 node.js, web 개발자에게도 헷갈리는 상황인데, observable 의 개발자인 @tmcw 가 이 부분을 도와줄 수 있는 Module require debugger 란 멋진 디버깅 페이지를 만들었습니다. input 항목에 observable 에서 가져오고자 하는 모듈을 이름을 입력하면 가장 적합한 형태를 추천해 줍니다. 2019-01-22 update: https://bundle.run/phoenix-channels@1.0.0 에 문제가 있어, 운영자가 데모로 띄워둔 인스턴스 의 url 로 변경합니다.
pc = require('https://packd.now.sh/phoenix-channels@1.0.0')
이제 phoenix-channels 모듈을 가져와서 DAYBIT 의 websocket api ( ) 에 접속할 수 있습니다. 🎉🎉🎉
socket = { const socket = new pc.Socket('wss://api.daybit.com/v1/socket') socket.connect() return socket; }
지속적으로 전달되는 데이터를 다른 notebook cell 에 전파하기 Phoenix socket 이 만들어 졌으니, 채널에 참가할 수 있습니다. socket.channel 을 사용해 Channel 인스턴스를 생성하고, channel.join 함수를 호출해 채널에 참가할 수 있습니다. 채널에 참가가 성공한 후 request 메시지를 보내면 데이터를 받을 수 있습니다. DAYBIT api 는 subscription:market_summaries 채널에서 request를 보낸 클라이언트에게 전체 마켓에 대한 데이터를 먼저 내려주고, 업데이트가 있을 때마다, notification 메시지를 통해 업데이트 된 마켓의 정보를 내려줍니다.
mermaid` sequenceDiagram participant client participant server client->>+server: "join" server-->>client: "ok" client->>server: "request" server-->>client: "ok" Note over client,server: initial data loop every updated server-->>client: "notification" Note over client,server: updated data end client-->-server: end `
notification 메시지는 마켓이 업데이트 될 때마다 수신되기 때문에, notification 메시지를 수신하는 코드가 구현된 notebook cell 은 조금 다르게 구현되어야 합니다. observable notebook 의 cell 은 기본적으로 한 번만 실행되어 결과가 저장되기 때문입니다. cell 의존하는 다른 cell 이 다시 실행되지 않으면, cell 은 실행되지 않습니다. 스스로 계속 실행되면서, 자신에 의존하는 다른 cell 을 업데이트하는 cell 을 만들기 위해서는 Generator 를 사용해 cell 을 만들어야 합니다. 이는 RxJS 에서 새로운 Observable 인스턴스를 만드는 Observable.create 함수와 유사합니다.
value = Generators.observe(notify => { let i = 0 let s = setInterval(() => notify(i++ % 100), 2000) return () => clearInterval(s) })
Generator 로 만들어진 cell 을 참조하면 계속해서 업데이트 되는 cell 을 만들수 있습니다. ( )
notification 메시지가 수신될 때마다, 새로운 데이터를 nofify 콜백과 함께 호출하는 함수를 Generator.observe 에 전달해, 새로운 데이터가 수신될 때마다 결과값이 업데이트 되는 cell 을 만들 수 있습니다.
data = Generators.observe(notify => { let state = {}; const channel = socket.channel('/subscription:market_summaries;86400') channel.join() .receive('ok', () => { // 채널을 처음 구독하면 전체 데이터를 받습니다. channel .push('request', {timestamp: new Date() * 1000}) .receive('ok', ({data: [d]}) => { state = createState(state, d.data) notify(state) }) }) .receive('error', () => console.log('join error')) // 전체 데이터를 내려준 이후에는 업데이트된 항목만 받습니다. channel.on('notification', ({data: [d]}) => { state = createState(state, d.data) notify(state) }) return () => channel.leave() })
업데이트된 테이블 항목에 애니메이션을 적용하기 애니메이션이 없는 테이블... 변화를 알기 어렵습니다. 수치가 변경된 항목을 쉽게 확인할 수 있도록, table 에 간단한 css 스타일을 적용할 수 있습니다. 하지만 이 때, css 의 transition 프로퍼티를 사용할 수 없습니다. observable 의 html cell 은 react 처럼 변경된 DOM 만 우아하게 바꿔주는게 아니라, 매번 새로운 DOM 앨리먼트를 생성해서 교체하기 때문입니다. 따라서 기존의 상태가 필요한 transition 프로퍼티 보다는 animation 프로퍼티를 사용하는 것이 좋습니다. style cell 처럼 html tagged template literal 에 style 태그를 사용하면 notebook global 하게 적용되는 스타일을 만들 수 있습니다.
Appendix
createTable = (data, quote = 'BTC', anim = true) => { const rows = createRows(data, quote, anim) const table = width > 430 ? ` <table> <caption>${quote}</caption> <thead> <tr> <td align="center">Base</td> <td align="right">Price</td> <td align="right">Change</td> <td align="right">High</td> <td align="right">Low</td> <td align="right">Volume (24H)</td> </tr> </thead> <tbody> ${rows} </tbody> </table> ` : ` <table> <caption>${quote}</caption> <thead> <tr> <td align="center">Base</td> <td align="right">Price</td> <td align="right">Volume (24H)</td> </tr> </thead> <tbody> ${rows} </tbody> </table> ` return html`${table}` }
createRows = (data, quote, anim) => { return _.chain(data) .values() .filter(d => d.quote === quote && +d.quote_vol != 0) .orderBy(d => +d.quote_vol, 'desc') .map(({base, close, high, low, quote_vol, updated, volatility}) => { const tr = width > 430 ? ` <tr> <td>${base}</td> <td align="right" class="${updated.close && anim ? 'updated' : ''}">${close}</td> <td align="right" class="${updated.volatility && anim ? 'updated' : ''} ${changes(volatility)}">${volatility}</td> <td align="right" class="${updated.high && anim ? 'updated' : ''}">${high}</td> <td align="right" class="${updated.low && anim ? 'updated' : ''}">${low}</td> <td align="right" class="${updated.quote_vol && anim ? 'updated' : ''}">${quote_vol}</td> </tr> ` : ` <tr> <td>${base}</td> <td align="right" class="${updated.close && anim ? 'updated' : ''}">${close}</td> <td align="right" class="${updated.quote_vol && anim ? 'updated' : ''}">${quote_vol}</td> </tr> ` return tr }) .join('') .value() }
createState = (oldState, updatedData) => { const newState = _.reduce(updatedData, (acc, n) => { const market = `${n.base}/${n.quote}` const previous = oldState[market] const updated = !!previous ? diff(n, _.omit(previous, ['updated', 'previous'])) : {} return Object.assign(acc, { [market]: { ...n, updated, previous: _.omit(previous, ['updated', 'previous']) } }) }, oldState) return newState }
function diff(target, base) { function changes(target, base) { return _.transform(target, function(result, value, key) { if (!_.isEqual(value, base[key])) { result[key] = (_.isObject(value) && _.isObject(base[key])) ? changes(value, base[key]) : value; } }); } return changes(target, base); }
// updated: Object {quote_vol: "177.618", base_vol: "16050924.000"} // volatility: "-0.0099" // seconds: 86400 // quote_vol: "177.618" // quote: "BTC" // open: "0.00001116" // low: "0.0000109" // high: "0.0000117" // close: "0.00001105" // base_vol: "16050924.000" // base: "DAY" diff({ volatility: "-0.0099", seconds: 86400, quote_vol: "177.618", base_vol: "16050924.000" }, { volatility: "-0.0099", seconds: 86400, quote_vol: "177.618", quote: "BTC", open: "0.00001116", low: "0.0000109", high: "0.0000117", close: "0.00001105", base_vol: "16050924.000", base: "DAY" })
changes = val => { if (+val > 0) { return 'up' } else if (+val < 0) { return 'down' } return 'flat' }
mermaidPackage = require('https://packd.now.sh/mermaid@8.0.0')
function mermaid(...args) { let div = document.createElement('div') div.innerHTML = mermaidPackage.mermaidAPI.render(`mermaid-render-element-${guid()}`, String.raw(...args)) return div }
function guid() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { var r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8); return v.toString(16); }); }
url = 'wss://api.daybit.com/v1/socket'
import {icon} from "@chitacan/rss"
_ = require('lodash')
style = html`<style> @keyframes fade { 0% { background: rgba(198, 25, 25, .4); } 100% { background: none; } } @keyframes fade-delayed { 0% { background: rgba(198, 25, 25, .4); } 20%, 100% { background: none; } } .updated { animation: fade .7s ease-in; } .blink { animation: fade-delayed 3s ease-in infinite; } .up { color: #c61919; } .down { color: #0b5db3 } .flat { color: #aaa; } table > tbody {font-family: Menlo, Lucida Console, Courier, monospace;} table > caption { font-weight: bold; } </style> <link rel="stylesheet" href="https://unpkg.com/mermaid@7.0.0/dist/mermaid.css" />`