后端:使用 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 实例。 更多细节将在第二篇文章的末尾。

这是我们将要实现的架构:

显示 AWS Lambda 和 CloudWatch ping Node.js 后端的架构图,该后端通过使用 GitHub API 获取每日更新,然后构建基于 Gatsby 的前端,该前端使用后端 API 来更新其静态页面并部署到 Netlify。后端还通过免费计划部署到 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 入门指南应该会有所帮助。)

项目结构

我们项目的文件/文件夹层次结构很简单:

项目根目录的文件夹列表,显示 config、controller、model 和 node_modules 文件夹,以及一些标准根文件,如 index.js 和 package.json。前三个文件夹的文件遵循在给定文件夹中的每个文件名中重复文件夹名称的命名约定。

更详细地说:

  • 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文件目前只有portenvironmentdevprod )和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 存储库。 我们仅在上面的示例中添加了ownername参数,但这两个字段足以让我们访问有关给定存储库的一般信息。

为了更新我们的数据,我们必须使用 GitHub API。 对于这一部分,最好熟悉 GraphQL 和 v4 的 GitHub API。

我们还需要创建一个 GitHub 访问令牌。 最低要求的范围是:

我们需要的 GitHub 令牌范围是 repo:status、repo_deployment、public_repo、read:org 和 read:user。

这将生成一个令牌,我们可以用它向 GitHub 发送请求。

现在让我们回到我们的代码。

我们在package.json中有两个新的依赖项:

  • "axios": "^0.18.0"是一个 HTTP 客户端,所以我们可以向 GitHub API 发出请求
  • "cron": "^1.7.0"是一个 cron 作业调度程序

像往常一样,在添加依赖项后运行npm installyarn

我们还需要config.js中的两个新属性:

  • "githubEndpoint": "https://api.github.com/graphql"
  • "githubAccessToken": process.env.GITHUB_ACCESS_TOKEN (您需要使用您自己的个人访问令牌设置GITHUB_ACCESS_TOKEN环境变量)

controller文件夹下创建一个名为cron.controller.js的新文件。 它只会在预定时间调用repository.controller.jsupdateResositories方法:

 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 帐户后,从仪表板添加一个新应用程序:

从 Heroku 仪表板的新建菜单中选择“创建新应用程序”。

给它一个独特的名字:

在 Heroku 中命名您的应用程序。

您将被重定向到部署部分。 选择 GitHub 作为部署方式,搜索你的仓库,然后点击“连接”按钮:

将您的新 GitHub 存储库链接到您的 Heroku 应用程序。

为简单起见,您可以启用自动部署。 每当您将提交推送到 GitHub 存储库时,它都会部署:

在 Heroku 中启用自动部署。

现在我们必须将 MongoDB 添加为资源。 转到“资源”选项卡,然后单击“查找更多附加组件”。 (我个人使用 mLab mongoDB。)

将 MongoDB 资源添加到您的 Heroku 应用程序。

安装它并在“App to provision to”输入框中输入您的应用名称:

Heroku 中的 mLab MongoDB 插件配置页面。

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

相关: Node.js 开发人员最常犯的 10 个错误