后端:使用 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 内容生成器提供数据,并带有一个用于持续集成的钩子。 现在您已为第二部分做好准备,我们将在其中实现前端并完成应用程序!