使用 Apollo Server 轻松实现 GraphQL 文件上传(到 Amazon S3 和本地文件系统)
本文将以教程的形式演示如何在 Apollo Server 上处理文件上传,并将其流式传输到 Amazon S3,或者(可选,但不推荐)传输到服务器的文件系统。
在我们继续之前,我假设您对 S3 有基本的了解,并且已经阅读过Apollo 文档中关于这个主题的内容。
注:为了简洁起见,我尽量精简了内容(大部分情况下)。您可以根据自己的项目需求,从本文中提取最相关的内容并进行相应的调整。
文件结构概览
├── .env
├── tmp/
|
├── bin/
│ │
│ └── upload-avatar.sh
|
└── src/
│
├── config.js
├── uploaders.js
├── typedefs.js
├── resolvers.js
├── server.js
├── s3.js
├── index.js
|
└── lib/
│
└── gql-uploaders.js
- .env - dotenv 文件,我们将在其中保存我们的亚马逊凭证和其他有用的环境变量。
- src/lib/gql-uploaders - 我们为上传功能所做的抽象;
- src/config.js - 加载 .env 文件并以应用程序友好的格式导出其变量。
- src/server.js - 我们将在此处配置 GraphQL 服务器。
- src/resolvers.js - GraphQL 解析器。
- src/typedefs.js - GraphQL 类型定义。
- src/index.js - 应用程序入口点。
- src/uploaders.js - 我们的上传器抽象实例。
- src/s3.js - 导出我们配置的 AWS.S3 实例。
- bin/upload-avatar.sh - 一个 shell 实用程序,允许我们手动测试文件上传。
- tmp/ - 用于存储上传文件的临时目录。
安装依赖项
假设您已经创建了 package.json 文件并配置好了文件结构,现在应该安装以下依赖项(我将使用 yarn,但您当然也可以使用 npm 命令):
yarn add apollo-server graphql dotenv uuid aws-sdk
我们将使用GraphQL 服务器来驱动它,apollo-server加载环境变量,处理上传到 Amazon S3 云的请求,以及生成随机文件名的模块。graphqldotenvaws-sdkuuid
了解 Apollo Server 如何处理上传
我们将从编写 GraphQL 类型定义开始。
// src/typedefs.js -- final revision
const { gql } = require('apollo-server');
module.exports = gql`
type File {
uri: String!
filename: String!
mimetype: String!
encoding: String!
}
type Query {
uploads: [File]
}
type Mutation {
uploadAvatar(file: Upload!): File
}
`;
举个例子,我们将上传一张用户头像图片。这就是我们的 mutationuploadAvatar将要执行的操作。它会返回一个File类型,本质上就是一个包含uri已存储文件及其一些不太常用属性的对象。实际上,在本教程中我们不会直接使用查询uploads,但 GraphQL 要求我们有一个非空的根查询类型,所以我们才把它放在这里。请忽略它。
我们的uploadAvatarmutation 只有一个file类型为 的参数()Upload。我们的解析器将接收一个 Promise,该 Promise 会解析为一个包含以下属性的对象:
filename- 表示上传文件名称的字符串,例如my-pic-at-the-beach-20200120.jpg;mimetype- 表示上传文件的 MIME 类型的字符串,例如image/jpeg;encoding- 表示文件编码的字符串,例如7bit;createReadStream- 一个用于启动二进制读取流的函数(在之前的 Apollo 实现中,我们得到的是一个stream对象,而不是用于创建它的函数)。
如果你之前从未接触过Node流,可以查看Node的流API。但别被吓到,你很快就会发现,它的使用非常简单。
// src/resolvers.js -- first revision
module.exports = {
Mutation: {
uploadAvatar: async (_, { file }) => {
const { createReadStream, filename, mimetype, encoding } = await file;
return {
filename,
mimetype,
encoding,
uri: 'http://about:blank',
};
},
},
};
所以,在第一个版本中,我们只是简单地返回文件属性(URI 值用占位符表示)。稍后我们会再回来处理,以便有效地上传文件。
现在,让我们来搭建服务器:
// src/server.js -- final revision
const { ApolloServer } = require('apollo-server');
const typeDefs = require('./typedefs');
const resolvers = require('./resolvers');
module.exports = new ApolloServer({
typeDefs,
resolvers,
});
然后付诸实践:
// src/index.js -- final revision
const server = require('./server');
server.listen().then(({ url }) => {
console.log(`🚀 Server ready at ${url}`);
});
好了。现在是时候亲身体验一下了。我们将向服务器发送一个文件上传请求,看看它是如何运行的。由于我们需要多次测试文件上传,我们将创建一个 shell 脚本来帮我们发送请求(您可能需要允许它执行:)chmod +x ./bin/upload-avatar.sh。
#!/bin/sh
# bin/upload-avatar.sh -- final revision
curl $1 \
-F operations='{ "query": "mutation ($file: Upload!) { uploadAvatar(file: $file) { uri filename mimetype encoding } }", "variables": { "file": null } }' \
-F map='{ "0": ["variables.file"] }' \
-F 0=@$2
如果你觉得这段脚本有点晦涩难懂(我一开始也觉得很晦涩),别担心。详细解释脚本内容超出了本教程的范围,但我打算尽快写一篇关于如何制作 JavaScript 上传客户端的文章。同时,如果你想了解更多内部工作原理,可以点击这里查看。
脚本接收服务器 URI 作为第一个参数,文件路径作为第二个参数。我会上传一张名为 sexy-me.jpg 的非常性感的照片(你无缘得见),到我本地运行在 4000 端口的服务器上(别忘了启动你的服务器哦node src/index.js):
./bin/upload-avatar.sh localhost:4000 ~/Pictures/sexy-me.jpg
以下是格式化后的 JSON 响应:
{
"data": {
"uploadAvatar": {
"uri": "http://about:blank",
"filename": "sexy-me.jpg",
"mimetype": "image/jpeg",
"encoding": "7bit"
}
}
}
提示:您可以使用 'jq' 工具格式化 JSON 响应。安装 jq 并将响应通过管道传递给它,例如./bin/upload-avatar.sh localhost:4000 ~/Pictures/sexy-me.jpg | jq:
将文件上传到 Amazon S3
看起来不错。现在,我们来配置一下S3实例。
# .env -- final revision
AWS_ACCESS_KEY_ID=
AWS_ACCESS_KEY_SECRET=
AWS_S3_REGION=us-east-2
AWS_S3_BUCKET=acme-evil-labs
当然,这些变量的值需要您自行设定。
我们的配置模块如下所示:
// src/config.js -- final revision
require('dotenv').config()
module.exports = {
s3: {
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_ACCESS_KEY_SECRET,
},
region: process.env.AWS_S3_REGION,
params: {
ACL: 'public-read',
Bucket: process.env.AWS_S3_BUCKET,
},
},
app: {
storageDir: 'tmp',
},
};
让我们来配置S3实例:
// src/s3.js -- final revision
const AWS = require('aws-sdk');
const config = require('./config');
module.exports = new AWS.S3(config.s3);
现在是时候重新审视我们的解析器,并将文件实际上传到 S3 了:
// src/resolvers.js -- second revision
const { extname } = require('path');
const { v4: uuid } = require('uuid'); // (A)
const s3 = require('./s3'); // (B)
module.exports = {
Mutation: {
uploadAvatar: async (_, { file }) => {
const { createReadStream, filename, mimetype, encoding } = await file;
const { Location } = await s3.upload({ // (C)
Body: createReadStream(),
Key: `${uuid()}${extname(filename)}`,
ContentType: mimetype
}).promise();
return {
filename,
mimetype,
encoding,
uri: Location, // (D)
};
},
},
};
事情经过是这样的:
- (A):我们导入 UUID/V4 函数(作为 uuid)来生成我们的随机 UUID。
- (B):我们导入已配置的 S3 实例。
- (C):我们调用该
upload函数,并向其传递createReadStream三个参数:一个可读流对象(通过调用 `getStream()` 创建)Body;一个带有文件名后缀的随机 UUID 字符串Key;以及一个 MIME 类型ContentType。`getStreamupload()` 是一个异步函数,它需要一个回调函数,但我们可以通过调用 `returnPromise()` 方法从中返回一个 Promise 对象promise(在 JavaScript 中,函数也是对象)。当 Promise 被解析后,我们解构解析后的对象以提取 `file_url`Location属性(`Locationfile_url` 是我们可以从中下载上传文件的 URI)。 - (D):我们设置
uri为Location。
您可以在这里找到有关该upload功能的更多信息。
现在我们可以再次调用 shell 脚本./bin/upload-avatar.sh localhost:4000 ~/Pictures/sexy-me.jpg来查看结果:
{
"data": {
"uploadAvatar": {
"uri": "https://acme-evil-labs.s3.us-east-2.amazonaws.com/c3127c4c-e4f9-4e79-b3d1-08e2cbb7ad5d.jpg",
"filename": "sexy-me.jpg",
"mimetype": "image/jpeg",
"encoding": "7bit"
}
}
}
请注意,URI 现在指向亚马逊云。我们可以将该 URI 保存到数据库中,并将其提供给前端应用程序。此外,我们还可以复制该 URI(但不是本示例中的 URI)并粘贴到浏览器中,查看我们刚刚上传的文件(前提是我们的 S3 访问策略配置允许)。
这样做确实能完成工作,但如果我们想在其他解析器中复用该功能,并为同事提供一个友好易用的特性,就必须对该功能进行抽象。为此,我们将创建两个接口相同的上传器:一个用于将文件上传到 Amazon S3(S3Uploader),另一个用于将文件保存到本地硬盘(FilesystemUploader)。如今直接将文件上传到服务器硬盘的用例很少,但在开发过程中的某些时候可能会派上用场。届时我们将看到,我们可以无缝地将一个实现替换为另一个。
建筑抽象
让我们先从S3Uploader班级开始:
// src/lib/gql-uploaders.js -- first revision
const path = require('path');
const { v4: uuid } = require('uuid');
function uuidFilenameTransform(filename = '') { // (A)
const fileExtension = path.extname(filename);
return `${uuid()}${fileExtension}`;
}
class S3Uploader {
constructor(s3, config) {
const {
baseKey = '',
uploadParams = {},
concurrencyOptions = {},
filenameTransform = uuidFilenameTransform, // (A)
} = config;
this._s3 = s3;
this._baseKey = baseKey.replace('/$', ''); // (B)
this._filenameTransform = filenameTransform;
this._uploadParams = uploadParams;
this._concurrencyOptions = concurrencyOptions;
}
async upload(stream, { filename, mimetype }) {
const transformedFilename = this._filenameTransform(filename); // (A)
const { Location } = await this._s3
.upload(
{
...this._uploadParams, // (C)
Body: stream,
Key: `${this._baseKey}/${transformedFilename}`,
ContentType: mimetype,
},
this._concurrencyOptions
)
.promise();
return Location; // (D)
}
}
module.exports = { S3Uploader, uuidFilenameTransform };
S3Uploader构造函数接收一个 S3 实例和以下参数:baseKey这是每个上传文件的关键前缀。请注意,如果末尾有“/”,则会被删除(B);uploadParams- 传递给 S3 上传函数的默认params对象。这些参数将与上传方法(C)中更具体的参数混合使用。concurrencyOptions- 这些是底层 S3upload函数接受的并发选项;filenameTransform- 一个可自定义的文件名转换函数。它默认使用一个将随机 UUID 和文件扩展名(A)连接起来的函数。
- 当 Promise 解析时,我们返回文件的 URI (D)。
在实际应用之前,让我们先创建一个配置好的实例:
// src/uploaders.js -- first revision
const s3 = require('./s3');
const { S3Uploader } = require('./lib/gql-uploaders');
const avatarUploader = new S3Uploader(s3, {
baseKey: 'users/avatars',
uploadParams: {
CacheControl: 'max-age:31536000',
ContentDisposition: 'inline',
},
filenameTransform: filename => filename,
});
module.exports = { avatarUploader };
好了,这就是全部内容。我们添加了一些上传参数(CacheControl和),只是为了测试各种可能性。每次调用对象上的方法时都会用到这些参数。我们定义了一个函数,它只接收文件名并原封不动地返回,并将设置为,这样使用上传的文件就会以类似于的键存储在 S3 上。ContentDispotisionuploadavatarUploaderfilenameTransformbaseKey'users/avatars'avatarUplaoderusers/avatars/sexy-me.jpg
现在,它的妙处就在于此:让我们看看我们的解析器能变得多么简洁明了:
// src/resolvers.js -- final revision
const { avatarUploader } = require('./uploaders');
module.exports = {
Mutation: {
uploadAvatar: async (_, { file }) => {
const { createReadStream, filename, mimetype, encoding } = await file;
const uri = await avatarUploader.upload(createReadStream(), {
filename,
mimetype,
});
return {
filename,
mimetype,
encoding,
uri,
};
},
},
};
解析器部分就到此为止了。现在我们将实现自己的实现FilesystemUploader,并且会发现,切换实现时,我们甚至不需要修改解析器代码。
// src/lib/gql-uploaders.js -- final revision (partial file)
const fs = require('fs');
const path = require('path');
const { v4: uuid } = require('uuid');
// `uuidFilenameTransform` function definition....
// `S3Uploader` class definition...
class FilesystemUploader {
constructor(config = {}) {
const {
dir = '',
filenameTransform = uuidFilenameTransform
} = config;
this._dir = path.normalize(dir);
this._filenameTransform = filenameTransform;
}
upload(stream, { filename }) {
const transformedFilename = this._filenameTransform(filename);
const fileLocation = path.resolve(this._dir, transformedFilename);
const writeStream = stream.pipe(fs.createWriteStream(fileLocation));
return new Promise((resolve, reject) => {
writeStream.on('finish', resolve);
writeStream.on('error', reject);
}).then(() => `file://${fileLocation}`);
}
}
module.exports = {
S3Uploader,
FilesystemUploader,
uuidFilenameTransform
};
- 构造函数接受目标目录的文件系统路径
dir。 filenameTransform参数与以下参数类似S3Uploader。- 该
upload方法创建一个写入流,用于将文件写入目录dir。然后,它将读取流通过管道传递给写入流。它upload返回一个 Promise,该 Promise 监听写入流事件,并在写入操作成功时解析为驱动器上的文件 URI。
让我们回到 src/uploaders.js 文件,切换一下实现方式。我们只需将导出名称的引用替换为新的实现即可,但如果您需要根据条件切换,也可以实现更复杂的功能,例如使用策略模式。
// src/uploaders.js -- final revision
const s3 = require('./s3');
const config = require('./config');
const {
S3Uploader,
FilesystemUploader,
} = require('./lib/gql-uploaders');
const s3AvatarUploader = new S3Uploader(s3, { // (A)
baseKey: 'users/avatars',
uploadParams: {
CacheControl: 'max-age:31536000',
ContentDisposition: 'inline',
},
});
const fsAvatarUploader = new FilesystemUploader({ // (A)
dir: config.app.storageDir, // (B)
filenameTransform: filename => `${Date.now()}_${filename}`, // (C)
});
module.exports = { avatarUploader: fsAvatarUploader }; // (A)
- (A):现在我们有两个实现,
s3AvatarUploader和fsAvatarUploader。这次我们将导出fsAvatarUploader为avatarUploader。 - (B):我指的是我在项目根文件夹中创建的 tmp 目录。
- (C):我们
filenameTransform再次进行自定义,只是为了再次展示它的实际效果。此实现会在文件名前添加当前时间戳。请注意,我还省略了此参数s3AvatarUploader,将其重置为默认算法(随机 UUID 文件名);
好了,话不多说!让我们看看我们有什么!
我又跑了./bin/upload-avatar.sh http://localhost:4000 ~/Pictures/sexy-me.jpg一遍,结果是:
{
"data": {
"uploadAvatar": {
"uri": "file:///home/fhpriamo/blogpost-graphql-uploads/tmp/1586733824916_sexy-me.jpg",
"filename": "sexy-me.jpg",
"mimetype": "image/jpeg",
"encoding": "7bit"
}
}
}
太棒了!我们甚至都不用重写解析器!
Git 仓库
你可以在这里查看完整代码。克隆它、修改它、尝试它、扩展它……一切由你决定。
注意:如果您克隆了仓库并想要运行它,请不要忘记自己编写一个 .env 文件(如果您需要模板,可以参考 .env.example)。