发布于 2026-01-06 2 阅读
0

使用 Apollo Server 轻松实现 GraphQL 文件上传(到 Amazon S3 和本地文件系统)

使用 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
Enter fullscreen mode Exit fullscreen mode
  • .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
Enter fullscreen mode Exit fullscreen mode

我们将使用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
  }
`;

Enter fullscreen mode Exit fullscreen mode

举个例子,我们将上传一张用户头像图片。这就是我们的 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',
      };
    },
  },
};

Enter fullscreen mode Exit fullscreen mode

所以,在第一个版本中,我们只是简单地返回文件属性(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,
});

Enter fullscreen mode Exit fullscreen mode

然后付诸实践:

// src/index.js -- final revision

const server = require('./server');

server.listen().then(({ url }) => {
  console.log(`🚀 Server ready at ${url}`);
});

Enter fullscreen mode Exit fullscreen mode

好了。现在是时候亲身体验一下了。我们将向服务器发送一个文件上传请求,看看它是如何运行的。由于我们需要多次测试文件上传,我们将创建一个 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

Enter fullscreen mode Exit fullscreen mode

如果你觉得这段脚本有点晦涩难懂(我一开始也觉得很晦涩),别担心。详细解释脚本内容超出了本教程的范围,但我打算尽快写一篇关于如何制作 JavaScript 上传客户端的文章。同时,如果你想了解更多内部工作原理,可以点击这里查看。

脚本接收服务器 URI 作为第一个参数,文件路径作为第二个参数。我会上传一张名为 sexy-me.jpg 的非常性感的照片(你无缘得见),到我本地运行在 4000 端口的服务器上(别忘了启动你的服务器哦node src/index.js):

./bin/upload-avatar.sh localhost:4000 ~/Pictures/sexy-me.jpg
Enter fullscreen mode Exit fullscreen mode

以下是格式化后的 JSON 响应:

{
  "data": {
    "uploadAvatar": {
      "uri": "http://about:blank",
      "filename": "sexy-me.jpg",
      "mimetype": "image/jpeg",
      "encoding": "7bit"
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

提示:您可以使用 '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

Enter fullscreen mode Exit fullscreen mode

当然,这些变量的值需要您自行设定。

我们的配置模块如下所示:

// 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',
  },
};

Enter fullscreen mode Exit fullscreen mode

让我们来配置S3实例:

// src/s3.js -- final revision

const AWS = require('aws-sdk');
const config = require('./config');

module.exports = new AWS.S3(config.s3);

Enter fullscreen mode Exit fullscreen mode

现在是时候重新审视我们的解析器,并将文件实际上传到 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)
      }; 
    },
  },
};

Enter fullscreen mode Exit fullscreen mode

事情经过是这样的:

  • (A):我们导入 UUID/V4 函数(作为 uuid)来生成我们的随机 UUID。
  • (B):我们导入已配置的 S3 实例。
  • (C):我们调用该upload函数,并向其传递createReadStream三个参数:一个可读流对象(通过调用 `getStream()` 创建)Body;一个带有文件名后缀的随机 UUID 字符串Key;以及一个 MIME 类型ContentType。`getStream upload()` 是一个异步函数,它需要一个回调函数,但我们可以通过调用 `returnPromise()` 方法从中返回一个 Promise 对象promise(在 JavaScript 中,函数也是对象)。当 Promise 被解析后,我们解构解析后的对象以提取 `file_url`Location属性(` Locationfile_url` 是我们可以从中下载上传文件的 URI)。
  • (D):我们设置uriLocation

您可以在这里找到有关该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"
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

请注意,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 };

Enter fullscreen mode Exit fullscreen mode
  • 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 };

Enter fullscreen mode Exit fullscreen mode

好了,这就是全部内容。我们添加了一些上传参数(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,
      };
    },
  },
};

Enter fullscreen mode Exit fullscreen mode

解析器部分就到此为止了。现在我们将实现自己的实现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
};

Enter fullscreen mode Exit fullscreen mode
  • 构造函数接受目标目录的文件系统路径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)

Enter fullscreen mode Exit fullscreen mode
  • (A):现在我们有两个实现,s3AvatarUploaderfsAvatarUploader。这次我们将导出fsAvatarUploaderavatarUploader
  • (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"
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

太棒了!我们甚至都不用重写解析器!

Git 仓库

你可以在这里查看完整代码。克隆它、修改它、尝试它、扩展它……一切由你决定。

注意:如果您克隆了仓库并想要运行它,请不要忘记自己编写一个 .env 文件(如果您需要模板,可以参考 .env.example)。

相关文章:

文章来源:https://dev.to/fhpriamo/painless-graphql-file-uploads-with-apollo-server-to-amazon-s3-and-local-filesystem-1bn0