使用 React、Apollo GraphQL 和 Hasura 構建股票價格通知應用程序

已發表: 2022-03-10
快速總結↬在本文中,我們將學習如何構建基於事件的應用程序並在觸發特定事件時發送網絡推送通知。 我們將在 Hasura GraphQL 引擎上設置數據庫表、事件和預定觸發器,並將 GraphQL 端點連接到前端應用程序以記錄用戶的股票價格偏好。

與粘在連續數據流上以自己查找特定事件相比,在您選擇的事件發生時得到通知的概念變得流行起來。 人們更喜歡在他們喜歡的事件發生時收到相關的電子郵件/消息,而不是被掛在屏幕上等待該事件發生。 基於事件的術語在軟件領域也很常見。

如果您可以在手機上獲取您最喜歡的股票價格的最新信息,那該有多棒?

在本文中,我們將使用 React、Apollo GraphQL 和 Hasura GraphQL 引擎構建一個Stocks Price Notifier應用程序。 我們將從create-react-app樣板代碼開始該項目,並將構建所有內容。 我們將學習如何在 Hasura 控制台上設置數據庫表和事件。 我們還將學習如何使用網絡推送通知連接 Hasura 的事件以獲取股票價格更新。

快速瀏覽一下我們將要構建的內容:

股票價格通知器應用程序概述
股價通知申請

我們走吧!

跳躍後更多! 繼續往下看↓

這個項目是關於什麼的概述

股票數據(包括highlowopenclosevolume等指標)將存儲在 Hasura 支持的 Postgres 數據庫中。 用戶將能夠根據某些價值訂閱特定股票,或者他可以選擇每小時收到通知。 滿足訂閱條件後,用戶將收到網絡推送通知。

這看起來有很多東西,對於我們將如何構建這些部分,顯然會有一些懸而未決的問題。

以下是我們如何分四個步驟完成這個項目的計劃:

  1. 使用 NodeJs 腳本獲取股票數據
    我們將首先使用一個簡單的 NodeJs 腳本從股票 API 的提供者之一——Alpha Vantage 獲取股票數據。 此腳本將每隔 5 分鐘獲取特定股票的數據。 API 的響應包括平和成交量。 然後將這些數據插入與 Hasura 後端集成的 Postgres 數據庫中。
  2. 設置 Hasura GraphQL 引擎
    然後我們將在 Postgres 數據庫上設置一些表來記錄數據點。 Hasura 自動為這些表生成 GraphQL 模式、查詢和突變。
  3. 使用 React 和 Apollo 客戶端的前端
    下一步是使用 Apollo 客戶端和 Apollo Provider(Hasura 提供的 GraphQL 端點)集成 GraphQL 層。 數據點將在前端顯示為圖表。 我們還將構建訂閱選項,並將在 GraphQL 層上觸發相應的突變。
  4. 設置事件/預定觸發器
    Hasura 為觸發器提供了出色的工具。 我們將在股票數據表中添加事件和預定觸發器。 如果用戶有興趣在股票價格達到特定值時收到通知(事件觸發器),則將設置這些觸發器。 用戶還可以選擇每小時獲取特定股票的通知(預定觸發)。

現在計劃已經準備好了,讓我們付諸行動吧!

這是該項目的 GitHub 存儲庫。 如果您在下面的代碼中迷失了方向,請參閱此存儲庫並恢復速度!

使用 NodeJs 腳本獲取股票數據

這並不像聽起來那麼複雜! 我們必須編寫一個使用 Alpha Vantage 端點獲取數據的函數,並且這個 fetch 調用應該在5 分鐘的間隔內觸發(你猜對了,我們必須把這個函數調用放在setInterval中)。

如果您仍然想知道 Alpha Vantage 是什麼,並且只是想在跳到編碼部分之前擺脫它,那麼這裡是:

Alpha Vantage Inc. 是免費 API 的領先提供商,可提供股票、外匯 (FX) 和數字/加密貨幣的實時和歷史數據。

我們將使用此端點來獲取特定股票的所需指標。 此 API 需要一個 API 密鑰作為參數之一。 您可以從這裡獲得免費的 API 密鑰。 現在我們可以進入有趣的部分了——讓我們開始編寫一些代碼吧!

安裝依賴

創建一個stocks-app目錄並在其中創建一個server目錄。 使用npm init將其初始化為節點項目,然後安裝這些依賴項:

 npm i isomorphic-fetch pg nodemon --save

這是我們編寫獲取股票價格並將它們存儲在 Postgres 數據庫中的腳本所需的僅有的三個依賴項。

以下是對這些依賴項的簡要說明:

  • isomorphic-fetch
    它使得在客戶端和服務器上都可以輕鬆地以同構方式(以相同的形式)使用fetch
  • pg
    它是 NodeJs 的非阻塞 PostgreSQL 客戶端。
  • nodemon
    它會在目錄中的任何文件更改時自動重新啟動服務器。

設置配置

在根級別添加config.js文件。 現在在該文件中添加以下代碼片段:

 const config = { user: '<DATABASE_USER>', password: '<DATABASE_PASSWORD>', host: '<DATABASE_HOST>', port: '<DATABASE_PORT>', database: '<DATABASE_NAME>', ssl: '<IS_SSL>', apiHost: 'https://www.alphavantage.co/', }; module.exports = config;

userpasswordhostportdatabasessl與 Postgres 配置相關。 我們會在設置 Hasura 引擎部分時回來編輯它!

初始化 Postgres 連接池以查詢數據庫

connection pool是計算機科學中的一個常用術語,您在處理數據庫時經常會聽到這個術語。

在數據庫中查詢數據時,您必須首先建立與數據庫的連接。 此連接接收數據庫憑據,並為您提供查詢數據庫中任何表的掛鉤。

注意建立數據庫連接的成本很高,而且會浪費大量資源。 連接池緩存數據庫連接並在後續查詢中重新使用它們。 如果所有打開的連接都在使用中,則建立一個新連接,然後將其添加到池中。

現在已經清楚了連接池是什麼以及它的用途,讓我們開始為這個應用程序創建一個pg連接池的實例:

在根級別添加pool.js文件並創建一個池實例:

 const { Pool } = require('pg'); const config = require('./config'); const pool = new Pool({ user: config.user, password: config.password, host: config.host, port: config.port, database: config.database, ssl: config.ssl, }); module.exports = pool;

上面的代碼行使用配置文件中設置的配置選項創建了一個Pool實例。 我們尚未完成配置文件,但不會有與配置選項相關的任何更改。

我們現在已經做好準備,準備開始對 Alpha Vantage 端點進行一些 API 調用。

讓我們進入有趣的部分!

獲取股票數據

在本節中,我們將從 Alpha Vantage 端點獲取股票數據。 這是index.js文件:

 const fetch = require('isomorphic-fetch'); const getConfig = require('./config'); const { insertStocksData } = require('./queries'); const symbols = [ 'NFLX', 'MSFT', 'AMZN', 'W', 'FB' ]; (function getStocksData () { const apiConfig = getConfig('apiHostOptions'); const { host, timeSeriesFunction, interval, key } = apiConfig; symbols.forEach((symbol) => { fetch(`${host}query/?function=${timeSeriesFunction}&symbol=${symbol}&interval=${interval}&apikey=${key}`) .then((res) => res.json()) .then((data) => { const timeSeries = data['Time Series (5min)']; Object.keys(timeSeries).map((key) => { const dataPoint = timeSeries[key]; const payload = [ symbol, dataPoint['2. high'], dataPoint['3. low'], dataPoint['1. open'], dataPoint['4. close'], dataPoint['5. volume'], key, ]; insertStocksData(payload); }); }); }) })()

出於本項目的目的,我們將僅查詢這些股票的價格——NFLX (Netflix)、MSFT (Microsoft)、AMZN (Amazon)、W (Wayfair)、FB (Facebook)。

有關配置選項,請參閱此文件。 IIFE getStocksData函數沒有做太多! 它遍歷這些符號並查詢 Alpha Vantage 端點${host}query/?function=${timeSeriesFunction}&symbol=${symbol}&interval=${interval}&apikey=${key}以獲取這些股票的指標。

insertStocksData函數將這些數據點放入 Postgres 數據庫中。 這是insertStocksData函數:

 const insertStocksData = async (payload) => { const query = 'INSERT INTO stock_data (symbol, high, low, open, close, volume, time) VALUES ($1, $2, $3, $4, $5, $6, $7)'; pool.query(query, payload, (err, result) => { console.log('result here', err); }); };

就是這個! 我們從 Alpha Vantage API 中獲取了股票的數據點,並編寫了一個函數將這些數據點放入 Postgres 數據庫的stock_data表中。 只需缺少一件即可完成所有這些工作! 我們必須在配置文件中填充正確的值。 我們將在設置 Hasura 引擎後獲得這些值。 讓我們馬上開始吧!

有關從 Alpha Vantage 端點獲取數據點並將其填充到 Hasura Postgres 數據庫的完整代碼,請參閱server目錄。

如果這種使用原始查詢設置連接、配置選項和插入數據的方法看起來有點困難,請不要擔心! 一旦設置了 Hasura 引擎,我們將學習如何使用 GraphQL 突變輕鬆完成所有這些工作!

設置 Hasura GraphQL 引擎

設置 Hasura 引擎並使用 GraphQL 模式、查詢、突變、訂閱、事件觸發器等啟動和運行非常簡單!

點擊 Try Hasura 並輸入項目名稱:

創建 Hasura 項目
創建 Hasura 項目。 (大預覽)

我正在使用 Heroku 上託管的 Postgres 數據庫。 在 Heroku 上創建一個數據庫並將其鏈接到該項目。 然後,您應該準備好體驗查詢豐富的 Hasura 控制台的強大功能。

請複制創建項目後獲得的 Postgres DB URL。 我們必須把它放在配置文件中。

單擊啟動控制台,您將被重定向到此視圖:

Hasura 控制台
哈蘇拉控制台。 (大預覽)

讓我們開始構建這個項目所需的表模式。

在 Postgres 數據庫上創建表模式

請轉到“數據”選項卡並單擊“添加表”! 讓我們開始創建一些表:

symbol

該表將用於存儲符號的信息。 目前,我在這裡保留了兩個字段—— idcompany 。 字段id是主鍵, companyvarchar類型。 讓我們在此表中添加一些符號:

符號表
symbol表。 (大預覽)

stock_data

stock_data表存儲idsymboltime以及highlowopenclosevolume等指標。 我們在本節前面編寫的 NodeJs 腳本將用於填充此特定表。

表格如下所示:

stock_data 表
stock_data表。 (大預覽)

整潔的! 讓我們進入數據庫模式中的另一個表!

user_subscription

user_subscription表根據用戶 ID 存儲訂閱對象。 此訂閱對像用於向用戶發送網絡推送通知。 我們將在本文後面了解如何生成此訂閱對象。

該表中有兩個字段 - iduuid類型的主鍵,訂閱字段是jsonb類型。

events

這是重要的一個,用於存儲通知事件選項。 當用戶選擇特定股票的價格更新時,我們將該事件信息存儲在此表中。 此表包含以下列:

  • id :是具有自動增量屬性的主鍵。
  • symbol :是一個文本字段。
  • user_id :屬於uuid類型。
  • trigger_type :用於存儲事件觸發類型—— time/event
  • trigger_value :用於存儲觸發值。 例如,如果用戶選擇了基於價格的事件觸發器——如果股票價格達到 1000,他想要更新,那麼trigger_value將是 1000 並且trigger_type將是event

這些是我們在這個項目中需要的所有表格。 我們還必須在這些表之間建立關係,以實現順暢的數據流和連接。 讓我們這樣做吧!

建立表之間的關係

events表用於根據事件值發送網絡推送通知。 因此,將這個表與user_subscription表連接起來是有意義的,以便能夠發送關於存儲在這個表中的訂閱的推送通知。

 events.user_id → user_subscription.id

stock_data表與符號表相關為:

 stock_data.symbol → symbol.id

我們還必須在symbol表上構建一些關係:

 stock_data.symbol → symbol.id events.symbol → symbol.id

我們現在已經創建了所需的表並建立了它們之間的關係! 讓我們切換到控制台上的GRAPHIQL選項卡,看看它的神奇之處!

Hasura 已經根據這些表設置了 GraphQL 查詢:

Hasura 控制台上的 GraphQL 查詢/突變
Hasura 控制台上的 GraphQL 查詢/突變。 (大預覽)

查詢這些表很簡單,您還可以應用這些過濾器/屬性中的任何一個( distinct_onlimitoffsetorder_bywhere )來獲取所需的數據。

這一切看起來都不錯,但我們還沒有將我們的服務器端代碼連接到 Hasura 控制台。 讓我們完成那一點!

將 NodeJs 腳本連接到 Postgres 數據庫

請將所需選項放在server目錄中的config.js文件中,如下所示:

 const config = { databaseOptions: { user: '<DATABASE_USER>', password: '<DATABASE_PASSWORD>', host: '<DATABASE_HOST>', port: '<DATABASE_PORT>', database: '<DATABASE_NAME>', ssl: true, }, apiHostOptions: { host: 'https://www.alphavantage.co/', key: '<API_KEY>', timeSeriesFunction: 'TIME_SERIES_INTRADAY', interval: '5min' }, graphqlURL: '<GRAPHQL_URL>' }; const getConfig = (key) => { return config[key]; }; module.exports = getConfig;

請從我們在 Heroku 上創建 Postgres 數據庫時生成的數據庫字符串中輸入這些選項。

apiHostOptions由與 API 相關的選項組成,例如hostkeytimeSeriesFunctioninterval

您將在 Hasura 控制台的GRAPHIQL選項卡中獲得graphqlURL字段。

getConfig函數用於從配置對象返回請求的值。 我們已經在server目錄的index.js中使用了它。

是時候運行服務器並在數據庫中填充一些數據了。 我在package.json中添加了一個腳本:

 "scripts": { "start": "nodemon index.js" }

在終端上運行npm start並且index.js中符號數組的數據點應該填充到表中。

將 NodeJs 腳本中的原始查詢重構為 GraphQL 變異

現在已經設置了 Hasura 引擎,讓我們看看在stock_data表上調用突變是多麼容易。

queries.js中的函數insertStocksData使用原始查詢:

 const query = 'INSERT INTO stock_data (symbol, high, low, open, close, volume, time) VALUES ($1, $2, $3, $4, $5, $6, $7)';

讓我們重構這個查詢並使用 Hasura 引擎驅動的突變。 這是服務器目錄中重構的queries.js

 const { createApolloFetch } = require('apollo-fetch'); const getConfig = require('./config'); const GRAPHQL_URL = getConfig('graphqlURL'); const fetch = createApolloFetch({ uri: GRAPHQL_URL, }); const insertStocksData = async (payload) => { const insertStockMutation = await fetch({ query: `mutation insertStockData($objects: [stock_data_insert_input!]!) { insert_stock_data (objects: $objects) { returning { id } } }`, variables: { objects: payload, }, }); console.log('insertStockMutation', insertStockMutation); }; module.exports = { insertStocksData }

請注意:我們必須在config.js文件中添加graphqlURL

apollo-fetch模塊返回一個 fetch 函數,該函數可用於查詢/改變 GraphQL 端點上的日期。 很容易,對吧?

我們在index.js中要做的唯一更改是以insertStocksData函數所需的格式返回股票對象。 請查看index2.jsqueries2.js以獲取使用此方法的完整代碼。

現在我們已經完成了項目的數據端,讓我們進入前端部分並構建一些有趣的組件!

注意我們不必使用這種方法保留數據庫配置選項!

使用 React 和 Apollo 客戶端的前端

前端項目位於同一存儲庫中,並使用create-react-app包創建。 使用此包生成的服務工作者支持資產緩存,但它不允許將更多自定義項添加到服務工作者文件中。 已經有一些未解決的問題可以添加對自定義服務工作者選項的支持。 有一些方法可以解決這個問題並添加對自定義服務工作者的支持。

讓我們從前端項目的結構開始:

項目目錄
項目目錄。 (大預覽)

請檢查src目錄! 暫時不用擔心 service worker 相關的文件。 我們將在本節稍後部分了解有關這些文件的更多信息。 項目結構的其餘部分看起來很簡單。 components文件夾將包含組件(Loader、Chart); services文件夾包含一些用於在所需結構中轉換對象的輔助函數/服務; 顧名思義, styles包含用於設置項目樣式的 sass 文件; views是主目錄,它包含視圖層組件。

對於這個項目,我們只需要兩個視圖組件——符號列表和符號時間序列。 我們將使用 highcharts 庫中的 Chart 組件構建時間序列。 讓我們開始在這些文件中添加代碼來構建前端的各個部分!

安裝依賴

這是我們需要的依賴項列表:

  • apollo-boost
    Apollo boost 是開始使用 Apollo 客戶端的零配置方式。 它與默認配置選項捆綁在一起。
  • reactstrapbootstrap
    這些組件是使用這兩個包構建的。
  • graphqlgraphql-type-json
    graphql是使用apollo-boost的必需依賴項,而graphql-type-json用於支持 GraphQL 模式中使用的json數據類型。
  • highchartshighcharts-react-official
    這兩個包將用於構建圖表:

  • node-sass
    這是為了支持 sass 文件的樣式而添加的。

  • uuid
    該包用於生成強隨機值。

一旦我們開始在項目中使用它們,所有這些依賴關係就會變得有意義。 讓我們進入下一點!

設置 Apollo 客戶端

src文件夾中創建一個apolloClient.js ,如下所示:

 import ApolloClient from 'apollo-boost'; const apolloClient = new ApolloClient({ uri: '<HASURA_CONSOLE_URL>' }); export default apolloClient;

上面的代碼實例化了 ApolloClient,它在配置選項中接受了uriuri是 Hasura 控制台的 URL。 您將在GraphQL Endpoint部分的GRAPHIQL選項卡上獲得此uri字段。

上面的代碼看起來很簡單,但它處理了項目的主要部分! 它將基於 Hasura 構建的 GraphQL 模式與當前項目連接起來。

我們還必須將這個 apollo 客戶端對像傳遞給ApolloProvider並將根組件包裝在ApolloProvider中。 這將使主組件內的所有嵌套組件都可以使用client道具並在此客戶端對像上觸發查詢。

讓我們將index.js文件修改為:

 const Wrapper = () => { /* some service worker logic - ignore for now */ const [insertSubscription] = useMutation(subscriptionMutation); useEffect(() => { serviceWorker.register(insertSubscription); }, []) /* ignore the above snippet */ return <App />; } ReactDOM.render( <ApolloProvider client={apolloClient}> <Wrapper /> </ApolloProvider>, document.getElementById('root') );

請忽略insertSubscription相關代碼。 我們稍後會詳細了解。 其餘代碼應該很容易解決。 render函數接受根組件和 elementId 作為參數。 注意client (ApolloClient 實例)作為道具傳遞給ApolloProvider 。 您可以在此處查看完整的index.js文件。

設置自定義服務工作者

Service Worker 是一個能夠攔截網絡請求的 JavaScript 文件。 它用於查詢緩存以檢查請求的資產是否已經存在於緩存中,而不是乘車到服務器。 Service Worker 還用於向訂閱的設備發送網絡推送通知。

我們必須向訂閱用戶發送股票價格更新的網絡推送通知。 讓我們奠定基礎並構建這個 service worker 文件!

index.js文件中的insertSubscription相關片段負責註冊 service worker 並使用subscriptionMutation將訂閱對象放入數據庫中。

請參閱 queries.js 以了解項目中使用的所有查詢和突變。

serviceWorker.register(insertSubscription); 調用寫在serviceWorker.js文件中的register函數。 這裡是:

 export const register = (insertSubscription) => { if ('serviceWorker' in navigator) { const swUrl = `${process.env.PUBLIC_URL}/serviceWorker.js` navigator.serviceWorker.register(swUrl) .then(() => { console.log('Service Worker registered'); return navigator.serviceWorker.ready; }) .then((serviceWorkerRegistration) => { getSubscription(serviceWorkerRegistration, insertSubscription); Notification.requestPermission(); }) } }

上述函數首先檢查serviceWorker是否被瀏覽器支持,然後註冊託管在 URL swUrl上的 service worker 文件。 我們稍後會檢查這個文件!

getSubscription函數使用pushManager對像上的subscribe方法完成獲取訂閱對象的工作。 這個訂閱對象然後存儲在user_subscription表中,對應一個 userId。 請注意,userId 是使用uuid函數生成的。 讓我們看看getSubscription函數:

 const getSubscription = (serviceWorkerRegistration, insertSubscription) => { serviceWorkerRegistration.pushManager.getSubscription() .then ((subscription) => { const userId = uuidv4(); if (!subscription) { const applicationServerKey = urlB64ToUint8Array('<APPLICATION_SERVER_KEY>') serviceWorkerRegistration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }).then (subscription => { insertSubscription({ variables: { userId, subscription } }); localStorage.setItem('serviceWorkerRegistration', JSON.stringify({ userId, subscription })); }) } }) }

您可以查看serviceWorker.js文件以獲取完整代碼!

通知彈出窗口
通知彈出窗口。 (大預覽)

Notification.requestPermission()調用了這個彈出窗口,詢問用戶發送通知的權限。 一旦用戶點擊允許,推送服務就會生成訂閱對象。 我們將該對象存儲在 localStorage 中:

Webpush 訂閱對象
Webpush 訂閱對象。 (大預覽)

上述對像中的字段endpoint用於識別設備,服務器使用此端點向用戶發送 Web 推送通知。

我們已經完成了服務工作者的初始化和註冊工作。 我們還有用戶的訂閱對象! 由於public中存在serviceWorker.js文件,因此一切正常。 現在讓我們設置 service worker 來做好準備!

這是一個有點困難的話題,但讓我們做對吧! 如前所述, create-react-app實用程序默認不支持 service worker 的自定義。 我們可以使用workbox-build模塊來實現客戶服務工作者的實現。

我們還必須確保預緩存文件的默認行為完好無損。 我們將修改服務工作者在項目中構建的部分。 而且,workbox-build 有助於實現這一目標! 整潔的東西! 讓我們保持簡單並列出我們必須做的所有事情來使自定義服務工作者工作:

  • 使用workboxBuild處理資產的預緩存。
  • 創建用於緩存資產的服務工作者模板。
  • 創建sw-precache-config.js文件以提供自定義配置選項。
  • package.json的構建步驟中添加構建服務工作者腳本。

如果這一切聽起來令人困惑,請不要擔心! 這篇文章並不專注於解釋這些點背後的語義。 我們現在必須專注於實施部分! 我將嘗試在另一篇文章中介紹完成所有工作以創建自定義服務工作者的原因。

讓我們在src目錄下創建兩個文件sw-build.jssw-custom.js 。 請參考這些文件的鏈接並將代碼添加到您的項目中。

現在讓我們在根級別創建sw-precache-config.js文件,並在該文件中添加以下代碼:

 module.exports = { staticFileGlobs: [ 'build/static/css/**.css', 'build/static/js/**.js', 'build/index.html' ], swFilePath: './build/serviceWorker.js', stripPrefix: 'build/', handleFetch: false, runtimeCaching: [{ urlPattern: /this\\.is\\.a\\.regex/, handler: 'networkFirst' }] }

讓我們也修改package.json文件,為構建自定義 service worker 文件騰出空間:

scripts部分添加這些語句:

 "build-sw": "node ./src/sw-build.js", "clean-cra-sw": "rm -f build/precache-manifest.*.js && rm -f build/service-worker.js",

並將build腳本修改為:

 "build": "react-scripts build && npm run build-sw && npm run clean-cra-sw",

設置終於完成了! 我們現在必須在public文件夾中添加一個自定義的 service worker 文件:

 function showNotification (event) { const eventData = event.data.json(); const { title, body } = eventData self.registration.showNotification(title, { body }); } self.addEventListener('push', (event) => { event.waitUntil(showNotification(event)); })

我們剛剛添加了一個push監聽器來監聽服務器發送的推送通知。 函數showNotification用於向用戶顯示 Web 推送通知。

就是這個! 我們已經完成了設置自定義服務工作者來處理 Web 推送通知的所有艱苦工作。 一旦我們構建了用戶界面,我們就會看到這些通知的實際效果!

我們越來越接近構建主要代碼片段。 現在讓我們從第一個視圖開始!

符號列表視圖

上一節中使用的App組件如下所示:

 import React from 'react'; import SymbolList from './views/symbolList'; const App = () => { return <SymbolList />; }; export default App;

它是一個返回SymbolList視圖的簡單組件, SymbolList完成了在整齊綁定的用戶界面中顯示符號的所有繁重工作。

讓我們看看views文件夾中的symbolList.js

請參考這裡的文件!

該組件返回renderSymbols函數的結果。 並且,這些數據是使用useQuery鉤子從數據庫中獲取的:

 const { loading, error, data } = useQuery(symbolsQuery, {variables: { userId }});

symbolsQuery定義為:

 export const symbolsQuery = gql` query getSymbols($userId: uuid) { symbol { id company symbol_events(where: {user_id: {_eq: $userId}}) { id symbol trigger_type trigger_value user_id } stock_symbol_aggregate { aggregate { max { high volume } min { low volume } } } } } `;

它接受userId並獲取該特定用戶的訂閱事件以顯示通知圖標的正確狀態(與標題一起顯示的鈴鐺圖標)。 該查詢還獲取股票的最大值和最小值。 請注意上述查詢中aggregate的使用。 Hasura 的聚合查詢在後台完成工作以獲取聚合值,例如countsumavgmaxmin等。

根據上述 GraphQL 調用的響應,前端顯示的卡片列表如下:

股票卡
股票卡。 (大預覽)

卡片 HTML 結構如下所示:

 <div key={id}> <div className="card-container"> <Card> <CardBody> <CardTitle className="card-title"> <span className="company-name">{company} </span> <Badge color="dark" pill>{id}</Badge> <div className={classNames({'bell': true, 'disabled': isSubscribed})} id={`subscribePopover-${id}`}> <FontAwesomeIcon icon={faBell} title="Subscribe" /> </div> </CardTitle> <div className="metrics"> <div className="metrics-row"> <span className="metrics-row--label">High:</span> <span className="metrics-row--value">{max.high}</span> <span className="metrics-row--label">{' '}(Volume: </span> <span className="metrics-row--value">{max.volume}</span>) </div> <div className="metrics-row"> <span className="metrics-row--label">Low: </span> <span className="metrics-row--value">{min.low}</span> <span className="metrics-row--label">{' '}(Volume: </span> <span className="metrics-row--value">{min.volume}</span>) </div> </div> <Button className="timeseries-btn" outline onClick={() => toggleTimeseries(id)}>Timeseries</Button>{' '} </CardBody> </Card> <Popover className="popover-custom" placement="bottom" target={`subscribePopover-${id}`} isOpen={isSubscribePopoverOpen === id} toggle={() => setSubscribeValues(id, symbolTriggerData)} > <PopoverHeader> Notification Options <span className="popover-close"> <FontAwesomeIcon icon={faTimes} onClick={() => handlePopoverToggle(null)} /> </span> </PopoverHeader> {renderSubscribeOptions(id, isSubscribed, symbolTriggerData)} </Popover> </div> <Collapse isOpen={expandedStockId === id}> { isOpen(id) ? <StockTimeseries symbol={id}/> : null } </Collapse> </div>

我們使用 ReactStrap 的Card組件來渲染這些卡片。 Popover組件用於顯示基於訂閱的選項:

通知選項
通知選項。 (大預覽)

當用戶點擊特定股票的bell圖標時,他可以選擇每小時或股票價格達到輸入值時收到通知。 我們將在事件/時間觸發器部分看到這一點。

注意我們將在下一節中介紹StockTimeseries組件!

股票列表組件相關的完整代碼請參考symbolList.js

股票時間序列視圖

StockTimeseries組件使用查詢stocksDataQuery

 export const stocksDataQuery = gql` query getStocksData($symbol: String) { stock_data(order_by: {time: desc}, where: {symbol: {_eq: $symbol}}, limit: 25) { high low open close volume time } } `;

上述查詢獲取所選股票的最近 25 個數據點。 例如,這是 Facebook 股票開盤指標的圖表:

股票價格時間表
股票價格時間表。 (大預覽)

這是一個簡單的組件,我們將一些圖表選項傳遞給 [ HighchartsReact ] 組件。 以下是圖表選項:

 const chartOptions = { title: { text: `${symbol} Timeseries` }, subtitle: { text: 'Intraday (5min) open, high, low, close prices & volume' }, yAxis: { title: { text: '#' } }, xAxis: { title: { text: 'Time' }, categories: getDataPoints('time') }, legend: { layout: 'vertical', align: 'right', verticalAlign: 'middle' }, series: [ { name: 'high', data: getDataPoints('high') }, { name: 'low', data: getDataPoints('low') }, { name: 'open', data: getDataPoints('open') }, { name: 'close', data: getDataPoints('close') }, { name: 'volume', data: getDataPoints('volume') } ] }

X 軸顯示時間,Y 軸顯示當時的度量值。 函數getDataPoints用於為每個系列生成一系列點。

 const getDataPoints = (type) => { const values = []; data.stock_data.map((dataPoint) => { let value = dataPoint[type]; if (type === 'time') { value = new Date(dataPoint['time']).toLocaleString('en-US'); } values.push(value); }); return values; }

簡單的! 這就是圖表組件的生成方式! 有關股票時間序列的完整代碼,請參閱 Chart.js 和stockTimeseries.js文件。

您現在應該已準備好項目的數據和用戶界面部分。 現在讓我們進入有趣的部分——根據用戶的輸入設置事件/時間觸發器。

設置事件/預定觸發器

在本節中,我們將學習如何在 Hasura 控制台上設置觸發器以及如何向選定的用戶發送 Web 推送通知。 讓我們開始吧!

Hasura 控制台上的事件觸發器

讓我們在表stock_data上創建一個事件觸發器stock_valueinsert作為觸發器操作。 每次在stock_data表中有插入時,webhook 都會運行。

事件觸發器設置
事件觸發設置。 (大預覽)

我們將為 webhook URL 創建一個故障項目。 讓我簡單介紹一下 webhook 以便於理解:

Webhook 用於在發生特定事件時將數據從一個應用程序發送到另一個應用程序。 觸發事件時,將對 Webhook URL 進行 HTTP POST 調用,並將事件數據作為有效負載。

在這種情況下,當對stock_data表進行插入操作時,將對配置的 webhook URL 進行 HTTP post 調用(glitch 項目中的 post 調用)。

用於發送 Web 推送通知的故障項目

我們必須獲取 webhook URL 以放入上述事件觸發接口。 Go to glitch.com and create a new project. In this project, we'll set up an express listener and there will be an HTTP post listener. The HTTP POST payload will have all the details of the stock datapoint including open , close , high , low , volume , time . We'll have to fetch the list of users subscribed to this stock with the value equal to the close metric.

These users will then be notified of the stock price via web-push notifications.

That's all we've to do to achieve the desired target of notifying users when the stock price reaches the expected value!

Let's break this down into smaller steps and implement them!

安裝依賴

We would need the following dependencies:

  • express : is used for creating an express server.
  • apollo-fetch : is used for creating a fetch function for getting data from the GraphQL endpoint.
  • web-push : is used for sending web push notifications.

Please write this script in package.json to run index.js on npm start command:

 "scripts": { "start": "node index.js" }

Setting Up Express Server

Let's create an index.js file as:

 const express = require('express'); const bodyParser = require('body-parser'); const app = express(); app.use(bodyParser.json()); const handleStockValueTrigger = (eventData, res) => { /* Code for handling this trigger */ } app.post('/', (req, res) => { const { body } = req const eventType = body.trigger.name const eventData = body.event switch (eventType) { case 'stock-value-trigger': return handleStockValueTrigger(eventData, res); } }); app.get('/', function (req, res) { res.send('Hello World - For Event Triggers, try a POST request?'); }); var server = app.listen(process.env.PORT, function () { console.log(`server listening on port ${process.env.PORT}`); });

In the above code, we've created post and get listeners on the route / . get is simple to get around! We're mainly interested in the post call. If the eventType is stock-value-trigger , we'll have to handle this trigger by notifying the subscribed users. Let's add that bit and complete this function!

獲取訂閱用戶

const fetch = createApolloFetch({ uri: process.env.GRAPHQL_URL }); const getSubscribedUsers = (symbol, triggerValue) => { return fetch({ query: `query getSubscribedUsers($symbol: String, $triggerValue: numeric) { events(where: {symbol: {_eq: $symbol}, trigger_type: {_eq: "event"}, trigger_value: {_gte: $triggerValue}}) { user_id user_subscription { subscription } } }`, variables: { symbol, triggerValue } }).then(response => response.data.events) } const handleStockValueTrigger = async (eventData, res) => { const symbol = eventData.data.new.symbol; const triggerValue = eventData.data.new.close; const subscribedUsers = await getSubscribedUsers(symbol, triggerValue); const webpushPayload = { title: `${symbol} - Stock Update`, body: `The price of this stock is ${triggerValue}` } subscribedUsers.map((data) => { sendWebpush(data.user_subscription.subscription, JSON.stringify(webpushPayload)); }) res.json(eventData.toString()); }

在上面的handleStockValueTrigger函數中,我們首先使用getSubscribedUsers函數獲取訂閱用戶。 然後,我們向這些用戶中的每一個發送網絡推送通知。 函數sendWebpush用於發送通知。 稍後我們將看看 web-push 的實現。

函數getSubscribedUsers使用查詢:

 query getSubscribedUsers($symbol: String, $triggerValue: numeric) { events(where: {symbol: {_eq: $symbol}, trigger_type: {_eq: "event"}, trigger_value: {_gte: $triggerValue}}) { user_id user_subscription { subscription } } }

此查詢接受股票代碼和價值,並獲取用戶詳細信息,包括符合這些條件的user-id和用戶user_subscription

  • symbol等於在有效負載中傳遞的符號。
  • trigger_type等於event
  • trigger_value大於或等於傳遞給此函數的值(在本例中為close )。

一旦我們得到用戶列表,剩下的就是向他們發送網絡推送通知! 讓我們馬上去做!

向訂閱用戶發送 Web 推送通知

我們必須首先獲取公共和私有 VAPID 密鑰來發送網絡推送通知。 請將這些鍵存儲在.env文件中,並將這些詳細信息在index.js中設置為:

 webPush.setVapidDetails( 'mailto:<YOUR_MAIL_ID>', process.env.PUBLIC_VAPID_KEY, process.env.PRIVATE_VAPID_KEY ); const sendWebpush = (subscription, webpushPayload) => { webPush.sendNotification(subscription, webpushPayload).catch(err => console.log('error while sending webpush', err)) }

sendNotification函數用於在作為第一個參數提供的訂閱端點上發送 web-push。

這就是成功向訂閱用戶發送網絡推送通知所需的全部內容。 這是index.js中定義的完整代碼:

 const express = require('express'); const bodyParser = require('body-parser'); const { createApolloFetch } = require('apollo-fetch'); const webPush = require('web-push'); webPush.setVapidDetails( 'mailto:<YOUR_MAIL_ID>', process.env.PUBLIC_VAPID_KEY, process.env.PRIVATE_VAPID_KEY ); const app = express(); app.use(bodyParser.json()); const fetch = createApolloFetch({ uri: process.env.GRAPHQL_URL }); const getSubscribedUsers = (symbol, triggerValue) => { return fetch({ query: `query getSubscribedUsers($symbol: String, $triggerValue: numeric) { events(where: {symbol: {_eq: $symbol}, trigger_type: {_eq: "event"}, trigger_value: {_gte: $triggerValue}}) { user_id user_subscription { subscription } } }`, variables: { symbol, triggerValue } }).then(response => response.data.events) } const sendWebpush = (subscription, webpushPayload) => { webPush.sendNotification(subscription, webpushPayload).catch(err => console.log('error while sending webpush', err)) } const handleStockValueTrigger = async (eventData, res) => { const symbol = eventData.data.new.symbol; const triggerValue = eventData.data.new.close; const subscribedUsers = await getSubscribedUsers(symbol, triggerValue); const webpushPayload = { title: `${symbol} - Stock Update`, body: `The price of this stock is ${triggerValue}` } subscribedUsers.map((data) => { sendWebpush(data.user_subscription.subscription, JSON.stringify(webpushPayload)); }) res.json(eventData.toString()); } app.post('/', (req, res) => { const { body } = req const eventType = body.trigger.name const eventData = body.event switch (eventType) { case 'stock-value-trigger': return handleStockValueTrigger(eventData, res); } }); app.get('/', function (req, res) { res.send('Hello World - For Event Triggers, try a POST request?'); }); var server = app.listen(process.env.PORT, function () { console.log("server listening"); });

讓我們通過使用某個值訂閱 stock 並手動將該值插入表中來測試這個流程(用於測試)!

我訂閱了價值為2000AMZN ,然後用這個值在表中插入了一個數據點。 以下是股票通知應用程序在插入後立即通知我的方式:

在 stock_data 表中插入一行進行測試
在 stock_data 表中插入一行進行測試。 (大預覽)

整潔的! 您還可以在此處查看事件調用日誌:

事件日誌
事件日誌。 (大預覽)

webhook 正在按預期工作! 我們現在都準備好觸發事件了!

計劃/Cron 觸發器

我們可以使用 Cron 事件觸發器實現基於時間的觸發器,以每小時通知訂閱用戶用戶:

Cron/預定觸發器設置
Cron/計劃的觸發器設置。 (大預覽)

我們可以使用相同的 webhook URL 並根據觸發事件類型為stock_price_time_based_trigger處理訂閱用戶。 實現類似於基於事件的觸發器。

結論

在本文中,我們構建了一個股票價格通知器應用程序。 我們學習瞭如何使用 Alpha Vantage API 獲取價格並將數據點存儲在 Hasura 支持的 Postgres 數據庫中。 我們還學習瞭如何設置 Hasura GraphQL 引擎並創建基於事件和計劃的觸發器。 我們構建了一個故障項目,用於向訂閱用戶發送網絡推送通知。