後端:使用 Gatsby.js 和 Node.js 進行靜態站點更新
已發表: 2022-03-11在本系列文章中,我們將開發一個靜態內容網站原型。 它將為流行的 GitHub 存儲庫生成每日更新的簡單靜態 HTML 頁面,以跟踪其最新版本。 靜態網頁生成框架有很好的特性來實現這一點——我們將使用 Gatsby.js,它是最流行的之一。
在 Gatsby 中,有很多方法可以在沒有後端(無服務器)、無頭 CMS 平台和 Gatsby 源插件的情況下為前端收集數據。 但我們將實現一個後端來存儲有關 GitHub 存儲庫及其最新版本的基本信息。 因此,我們將完全控制我們的後端和前端。
此外,我將介紹一組工具來觸發您的應用程序的每日更新。 您也可以手動觸發它或在發生某些特定事件時觸發它。
我們的前端應用程序將在 Netlify 上運行,後端應用程序將使用免費計劃在 Heroku 上運行。 它會定期休眠:“當有人訪問應用程序時,dyno manager 會自動喚醒 web dyno 以運行 web 進程類型。” 因此,我們可以通過 AWS Lambda 和 AWS CloudWatch 喚醒它。 在撰寫本文時,這是讓原型 24/7 在線的最具成本效益的方式。
我們的節點靜態網站示例:期待什麼
為了使這些文章專注於一個主題,我不會涉及身份驗證、驗證、可伸縮性或其他一般主題。 本文的編碼部分將盡可能簡單。 項目的結構和正確的工具集的使用更為重要。
在本系列的第一部分中,我們將開發和部署我們的後端應用程序。 在第二部分,我們將開發和部署我們的前端應用程序,並觸發日常構建。
Node.js 後端
後端應用程序將用 Node.js 編寫(不是強制性的,但為了簡單起見),所有通信都將通過 REST API。 我們不會在這個項目中從前端收集數據。 (如果您對此感興趣,請查看 Gatsby Forms。)
首先,我們將從實現一個簡單的 REST API 後端開始,該後端公開 MongoDB 中存儲庫集合的 CRUD 操作。 然後我們將安排一個使用 GitHub API v4 (GraphQL) 的 cron 作業,以更新此集合中的文檔。 然後我們將所有這些部署到 Heroku 雲。 最後,我們將在 cron 作業結束時觸發前端的重建。
Gatsby.js 前端
在第二篇文章中,我們將重點介紹createPages
API 的實現。 我們將從後端收集所有存儲庫,並將生成一個主頁,其中包含所有存儲庫的列表,以及返回的每個存儲庫文檔的頁面。 然後我們將前端部署到 Netlify。
來自 AWS Lambda 和 AWS CloudWatch
如果您的應用程序不會休眠,這部分不是強制性的。 否則,您需要確保您的後端在更新存儲庫時已啟動並正在運行。 作為一種解決方案,您可以在每日更新前 10 分鐘在 AWS CloudWatch 上創建一個 cron 計劃,並將其作為觸發器綁定到 AWS Lambda 中的GET
方法。 訪問後端應用程序將喚醒 Heroku 實例。 更多細節將在第二篇文章的末尾。
這是我們將要實現的架構:
假設
我假設本文的讀者俱有以下領域的知識:
- HTML
- CSS
- JavaScript
- REST API
- MongoDB
- 吉特
- 節點.js
如果您知道以下內容也很好:
- Express.js
- 貓鼬
- GitHub API v4 (GraphQL)
- Heroku、AWS 或任何其他雲平台
- 反應
讓我們深入了解後端的實現。 我們將把它分成兩個任務。 第一個是準備 REST API 端點並將它們綁定到我們的存儲庫集合。 第二個是實現一個使用 GitHub API 並更新集合的 cron 作業。
開發 Node.js 靜態站點生成器後端,第 1 步:簡單的 REST API
我們將使用 Express 作為我們的 Web 應用程序框架,使用 Mongoose 作為我們的 MongoDB 連接。 如果您熟悉 Express 和 Mongoose,則可以跳到第 2 步。
(另一方面,如果您需要更熟悉 Express,可以查看官方的 Express 入門指南;如果您不熟悉 Mongoose,官方的 Mongoose 入門指南應該會有所幫助。)
項目結構
我們項目的文件/文件夾層次結構很簡單:
更詳細地說:
-
env.config.js
是環境變量配置文件 routes.config.js
用於映射其餘端點repository.controller.js
包含處理我們的存儲庫模型的方法repository.model.js
包含存儲庫和 CRUD 操作的 MongoDB 模式index.js
是一個初始化類package.json
包含依賴項和項目屬性
執行
將這些依賴項添加到package.json
後,運行npm install
(或yarn
,如果您安裝了 Yarn):
{ // ... "dependencies": { "body-parser": "1.7.0", "express": "^4.8.7", "moment": "^2.17.1", "moment-timezone": "^0.5.13", "mongoose": "^5.1.1", "node-uuid": "^1.4.8", "sync-request": "^4.0.2" } // ... }
我們的env.config.js
文件目前只有port
、 environment
( dev
或prod
)和mongoDbUri
屬性:
module.exports = { "port": process.env.PORT || 3000, "environment": "dev", "mongoDbUri": process.env.MONGODB_URI || "mongodb://localhost/github-consumer" };
routes.config.js
包含請求映射並將調用我們控制器的相應方法:
const RepositoryController = require('../controller/repository.controller'); exports.routesConfig = function(app) { app.post('/repositories', [ RepositoryController.insert ]); app.get('/repositories', [ RepositoryController.list ]); app.get('/repositories/:id', [ RepositoryController.findById ]); app.patch('/repositories/:id', [ RepositoryController.patchById ]); app.delete('/repositories/:id', [ RepositoryController.deleteById ]); };
repository.controller.js
文件是我們的服務層。 它的職責是調用我們存儲庫模型的相應方法:
const RepositoryModel = require('../model/repository.model'); exports.insert = (req, res) => { RepositoryModel.create(req.body) .then((result) => { res.status(201).send({ id: result._id }); }); }; exports.findById = (req, res) => { RepositoryModel.findById(req.params.id) .then((result) => { res.status(200).send(result); }); }; exports.list = (req, res) => { RepositoryModel.list() .then((result) => { res.status(200).send(result); }) }; exports.patchById = (req, res) => { RepositoryModel.patchById(req.params.id, req.body) .then(() => { res.status(204).send({}); }); }; exports.deleteById = (req, res) => { RepositoryModel.deleteById(req.params.id, req.body) .then(() => { res.status(204).send({}); }); };
repository.model.js
處理存儲庫模型的 MongoDb 連接和 CRUD 操作。 該模型的字段是:
-
owner
:存儲庫所有者(公司或用戶) -
name
:存儲庫名稱 createdAt
:最後一次發布創建日期resourcePath
: 最後的發布路徑tagName
: 最後一個發布標籤releaseDescription
: 發行說明homepageUrl
:項目的主頁 URL-
repositoryDescription
:存儲庫描述 avatarUrl
: 項目所有者的頭像 URL
const Mongoose = require('mongoose'); const Config = require('../config/env.config'); const MONGODB_URI = Config.mongoDbUri; Mongoose.connect(MONGODB_URI, { useNewUrlParser: true }); const Schema = Mongoose.Schema; const repositorySchema = new Schema({ owner: String, name: String, createdAt: String, resourcePath: String, tagName: String, releaseDescription: String, homepageUrl: String, repositoryDescription: String, avatarUrl: String }); repositorySchema.virtual('id').get(function() { return this._id.toHexString(); }); // Ensure virtual fields are serialised. repositorySchema.set('toJSON', { virtuals: true }); repositorySchema.findById = function(cb) { return this.model('Repository').find({ id: this.id }, cb); }; const Repository = Mongoose.model('repository', repositorySchema); exports.findById = (id) => { return Repository.findById(id) .then((result) => { if (result) { result = result.toJSON(); delete result._id; delete result.__v; return result; } }); }; exports.create = (repositoryData) => { const repository = new Repository(repositoryData); return repository.save(); }; exports.list = () => { return new Promise((resolve, reject) => { Repository.find() .exec(function(err, users) { if (err) { reject(err); } else { resolve(users); } }) }); }; exports.patchById = (id, repositoryData) => { return new Promise((resolve, reject) => { Repository.findById(id, function(err, repository) { if (err) reject(err); for (let i in repositoryData) { repository[i] = repositoryData[i]; } repository.save(function(err, updatedRepository) { if (err) return reject(err); resolve(updatedRepository); }); }); }) }; exports.deleteById = (id) => { return new Promise((resolve, reject) => { Repository.deleteOne({ _id: id }, (err) => { if (err) { reject(err); } else { resolve(err); } }); }); }; exports.findByOwnerAndName = (owner, name) => { return Repository.find({ owner: owner, name: name }); };
這是我們第一次提交後所擁有的:一個 MongoDB 連接和我們的 REST 操作。

我們可以使用以下命令運行我們的應用程序:
node index.js
測試
為了測試,向localhost:3000
發送請求(例如使用 Postman 或 cURL):
插入存儲庫(僅限必填字段)
發布:http://localhost:3000/repositories
身體:
{ "owner" : "facebook", "name" : "react" }
獲取存儲庫
獲取:http://localhost:3000/repositories
通過 ID 獲取
獲取:http://localhost:3000/repositories/:id
按 ID 打補丁
補丁:http://localhost:3000/repositories/:id
身體:
{ "owner" : "facebook", "name" : "facebook-android-sdk" }
有了這個工作,是時候自動更新了。
開發 Node.js 靜態站點生成器後端,第 2 步:更新存儲庫版本的 Cron 作業
在這一部分中,我們將配置一個簡單的 cron 作業(將從 UTC 午夜開始)來更新我們插入數據庫的 GitHub 存儲庫。 我們僅在上面的示例中添加了owner
和name
參數,但這兩個字段足以讓我們訪問有關給定存儲庫的一般信息。
為了更新我們的數據,我們必須使用 GitHub API。 對於這一部分,最好熟悉 GraphQL 和 v4 的 GitHub API。
我們還需要創建一個 GitHub 訪問令牌。 最低要求的範圍是:
這將生成一個令牌,我們可以用它向 GitHub 發送請求。
現在讓我們回到我們的代碼。
我們在package.json
中有兩個新的依賴項:
-
"axios": "^0.18.0"
是一個 HTTP 客戶端,所以我們可以向 GitHub API 發出請求 "cron": "^1.7.0"
是一個 cron 作業調度程序
像往常一樣,在添加依賴項後運行npm install
或yarn
。
我們還需要config.js
中的兩個新屬性:
-
"githubEndpoint": "https://api.github.com/graphql"
-
"githubAccessToken": process.env.GITHUB_ACCESS_TOKEN
(您需要使用您自己的個人訪問令牌設置GITHUB_ACCESS_TOKEN
環境變量)
在controller
文件夾下創建一個名為cron.controller.js
的新文件。 它只會在預定時間調用repository.controller.js
的updateResositories
方法:
const RepositoryController = require('../controller/repository.controller'); const CronJob = require('cron').CronJob; function updateDaily() { RepositoryController.updateRepositories(); } exports.startCronJobs = function () { new CronJob('0 0 * * *', function () {updateDaily()}, null, true, 'UTC'); };
這部分的最終更改將在repository.controller.js
中。 為簡潔起見,我們將其設計為一次更新所有存儲庫。 但是如果你有大量的存儲庫,你可能會超出 GitHub 的 API 的資源限制。 如果是這種情況,您需要修改它以在有限的批次中運行,隨著時間的推移分散開。
更新功能的一次性實現將如下所示:
async function asyncUpdate() { await RepositoryModel.list().then((array) => { const promises = array.map(getLatestRelease); return Promise.all(promises); }); } exports.updateRepositories = async function update() { console.log('GitHub Repositories Update Started'); await asyncUpdate().then(() => { console.log('GitHub Repositories Update Finished'); }); };
最後,我們將調用端點並更新存儲庫模型。
getLatestRelease
函數將生成 GraphQL 查詢並調用 GitHub API。 然後,該請求的響應將在updateDatabase
函數中進行處理。
async function updateDatabase(responseData, owner, name) { let createdAt = ''; let resourcePath = ''; let tagName = ''; let releaseDescription = ''; let homepageUrl = ''; let repositoryDescription = ''; let avatarUrl = ''; if (responseData.repository.releases) { createdAt = responseData.repository.releases.nodes[0].createdAt; resourcePath = responseData.repository.releases.nodes[0].resourcePath; tagName = responseData.repository.releases.nodes[0].tagName; releaseDescription = responseData.repository.releases.nodes[0].description; homepageUrl = responseData.repository.homepageUrl; repositoryDescription = responseData.repository.description; if (responseData.organization && responseData.organization.avatarUrl) { avatarUrl = responseData.organization.avatarUrl; } else if (responseData.user && responseData.user.avatarUrl) { avatarUrl = responseData.user.avatarUrl; } const repositoryData = { owner: owner, name: name, createdAt: createdAt, resourcePath: resourcePath, tagName: tagName, releaseDescription: releaseDescription, homepageUrl: homepageUrl, repositoryDescription: repositoryDescription, avatarUrl: avatarUrl }; await RepositoryModel.findByOwnerAndName(owner, name) .then((oldGitHubRelease) => { if (!oldGitHubRelease[0]) { RepositoryModel.create(repositoryData); } else { RepositoryModel.patchById(oldGitHubRelease[0].id, repositoryData); } console.log(`Updated latest release: http://github.com${repositoryData.resourcePath}`); }); } } async function getLatestRelease(repository) { const owner = repository.owner; const name = repository.name; console.log(`Getting latest release for: http://github.com/${owner}/${name}`); const query = ` query { organization(login: "${owner}") { avatarUrl } user(login: "${owner}") { avatarUrl } repository(owner: "${owner}", name: "${name}") { homepageUrl description releases(first: 1, orderBy: {field: CREATED_AT, direction: DESC}) { nodes { createdAt resourcePath tagName description } } } }`; const jsonQuery = JSON.stringify({ query }); const headers = { 'User-Agent': 'Release Tracker', 'Authorization': `Bearer ${GITHUB_ACCESS_TOKEN}` }; await Axios.post(GITHUB_API_URL, jsonQuery, { headers: headers }).then((response) => { return updateDatabase(response.data.data, owner, name); }); }
在我們第二次提交之後,我們將實施一個 cron 調度程序來從我們的 GitHub 存儲庫獲取每日更新。
我們幾乎完成了後端。 但是最後一步應該在實現前端之後完成,所以我們將在下一篇文章中介紹它。
將節點靜態站點生成器後端部署到 Heroku
在這一步中,我們會將我們的應用程序部署到 Heroku,因此如果您還沒有帳戶,則需要在他們那裡設置一個帳戶。 如果我們將我們的 Heroku 賬戶綁定到 GitHub,我們進行持續部署會容易得多。 為此,我將我的項目託管在 GitHub 上。
登錄您的 Heroku 帳戶後,從儀表板添加一個新應用程序:
給它一個獨特的名字:
您將被重定向到部署部分。 選擇 GitHub 作為部署方式,搜索你的倉庫,然後點擊“連接”按鈕:
為簡單起見,您可以啟用自動部署。 每當您將提交推送到 GitHub 存儲庫時,它都會部署:
現在我們必須將 MongoDB 添加為資源。 轉到“資源”選項卡,然後單擊“查找更多附加組件”。 (我個人使用 mLab mongoDB。)
安裝它並在“App to provision to”輸入框中輸入您的應用名稱:
最後,我們必須在項目的根級別創建一個名為Procfile
的文件,該文件指定應用程序在 Heroku 啟動時執行的命令。
我們的Procfile
就這麼簡單:
web: node index.js
創建文件並提交。 推送提交後,Heroku 將自動部署您的應用程序,該應用程序可通過https://[YOUR_UNIQUE_APP_NAME].herokuapp.com/
訪問。
要檢查它是否正常工作,我們可以發送我們發送到localhost
的相同請求。
Node.js、Express、MongoDB、Cron 和 Heroku:我們已經成功了一半!
在我們第三次提交之後,這就是我們的 repo 的樣子。
到目前為止,我們已經在後端實現了基於 Node.js/Express 的 REST API、使用 GitHub 的 API 的更新程序以及用於激活它的 cron 作業。 然後我們部署了後端,稍後將使用 Heroku 為我們的靜態 Web 內容生成器提供數據,並帶有一個用於持續集成的鉤子。 現在您已為第二部分做好準備,我們將在其中實現前端並完成應用程序!