GraphQL 및 Apollo를 사용하여 네이티브 파일 업로드에 반응하기

내 마지막 기사 이후로 몇 달이 지났습니다. 나는 KnobsAI 작업에 꽤 바빴고 글을 쓸 시간이 많지 않았습니다.

KnobsAI에서 파일 업로드 기능을 구현한 방법을 공유하는 것이 좋을 것 같아서 이에 대한 짧은 기사를 여기에서 제공합니다.

오늘 저는 GraphQL과 Apollo를 사용하여 React Native 앱에서 Digital Ocean Storage로 사진을 업로드하는 방법을 보여 드리겠습니다.

예제는 매우 간단하지만 더 복잡한 내용을 위한 토대를 마련합니다. 사진은 AWS API를 사용하는 Digital Ocean Storage에 업로드되지만 동일한 로직을 적용하여 다른 서비스에 업로드할 수 있습니다.

Digital Ocean Storage를 사용하는 경우 다음이 필요합니다.
  • Create a DigitalOcean Space & API Key
  • Add the Access Keys to the AWS Credentials file

  • 두 번째 링크의 가이드를 이 기능의 시작점으로 사용했습니다. 내 프로젝트와 오늘의 가이드에서 소개한 GraphQL을 사용하지 않습니다.

    포크하려는 경우에 대비하여 소스 코드가 있는 repo이 있습니다.

    서버 측 아키텍처

    The server side is comprised of three files: the index, the schema, and the storage.

    In the index.js file, we define our ApolloServer and the Express app. If you've already worked with GraphQL you may have done this differently as there are many ways to do it. The important thing here is the Storage service that is being passed in the ApolloServer context so every resolver can make use of it.

    const express = require('express');
    const Storage = require('./storage');
    const { ApolloServer } = require('apollo-server-express');
    const { typeDefs, resolvers } = require('./schema');
    
    const PORT = process.env.SERVER_PORT || 4000;
    
    const app = express();
    
    const server = new ApolloServer({
      typeDefs,
      resolvers,
      context: ({
        req,
        res,
      }) => ({
        req,
        res,
        Storage
      }),
      playground: {
        endpoint: `http://localhost:${PORT}/graphql`
      },
    });
    
    server.applyMiddleware({
      app
    });
    
    app.listen(PORT, () => {
      console.log(`Server listening on ${PORT}`);
    })
    

    The schema es where we define our mutation resolver that will receive the image object from the React Native app and pass it to the Storage service. As you can see, the Storage service is available via the context parameter because we injected it when setting up the server.

    const {
      gql,
      GraphQLUpload
    } = require('apollo-server-express');
    
    const uploadSchema = gql`
      type Query {
        _empty: String
      }
      type Mutation {
        uploadImage(
          image: Upload
        ): String
      }
    `
    
    module.exports = {
      typeDefs: [uploadSchema],
      resolvers: {
        Upload: GraphQLUpload,
        Mutation: {
          uploadImage: async (root, { image }, {
            Storage
          }) => {
            const folder = `rn-upload/`;
            try {
              const uploadResult = await Storage.upload(image, folder);
              return uploadResult.uri;
            } catch(e) {
              return new Error(e);
            }
          },
        }
      }
    };
    

    The Storage service is responsible for communicating with the Digital Ocean Storage via the AWS API. Remember from the guide above, you need to store the access keys to your bucket in a .aws/credentials file.

    An important thing to note here. The image property received in the resolver above is being sent using apollo-upload-client and it's an object containing a filename, a mime-type, the encoding, and a Read Stream.

    The Read Stream is what we need to pass to the s3.upload function as the Body . It took me some time to figure this out as I was passing the entire file object

    const aws = require('aws-sdk');
    const { v4: uuid } = require('uuid');
    const { extname } = require('path');
    
    // Set S3 endpoint to DigitalOcean Spaces
    const spacesEndpoint = new aws.Endpoint('nyc3.digitaloceanspaces.com');
    const s3 = new aws.S3({
      endpoint: spacesEndpoint,
      params: {
        ACL: 'public-read',
        Bucket: 'your-bucket-name',
      },
    });
    
    async function upload(file, folder){
    
      if(!file) return null;
    
      const { createReadStream, filename, mimetype, encoding } = await file;
    
      try {
        const { Location } = await s3.upload({ 
          Body: createReadStream(),               
          Key: `${folder}${uuid()}${extname(filename)}`,  
          ContentType: mimetype                   
        }).promise();         
    
        return {
          filename,
          mimetype,
          encoding,
          uri: Location, 
        }; 
      } catch(e) {
        return { error: { msg: 'Error uploading file' }};
      }
    }
    
    module.exports = {
      upload,
    };
    

    클라이언트 측 아키텍처

    As for the React Native side, the important thing here is integrating apollo-upload-client into the mix.We need to pass an upload link to our ApolloClient using createUploadLink .

    Also, don't forget to put your computer's IP if you're running the app on a simulator/emulator, or whatever IP you're using to run the server app.

    import React from 'react';
    import { ApolloClient } from '@apollo/client';
    import { InMemoryCache } from 'apollo-boost';
    import { createUploadLink } from 'apollo-upload-client';
    import { ApolloProvider } from '@apollo/react-hooks';
    import ImageUploader from './ImageUploader';
    
    // Use your computer's IP address if you're running the app in a simulator/emulator
    // Or the IP address of the server you're running the node backend
    const IP = '0.0.0.0'
    const uri = `http://${IP}:4000/graphql`;
    
    const client = new ApolloClient({
      link: createUploadLink({ uri }),
      cache: new InMemoryCache(),
    });
    
    export default function App() {
    
      return (
        <ApolloProvider client={client}>
          <ImageUploader />
        </ApolloProvider>
      );
    }
    

    If you happen to have several links you'd need to use ApolloLink.from as in the following example:

    const client = new ApolloClient({
      link: ApolloLink.from([
        errorLink,
        requestLink,
        createUploadLink({ uri }),
      ]),
      cache: new InMemoryCache(),
    });
    

    We then have an ImageUploader component, which uses an ImagePicker to let you choose an image from the phone's gallery and then calls the uploadImage mutation. The important thing here is to use the ReactNativeFile constructor from the apollo-upload-client package which will generate the object with the Read Stream we discussed above.

    Everything else is pretty much UI stuff like showing a loading spinner while the image is being uploaded and a status message when it fails or succeeds. If it succeeds it will display the URL where the image was uploaded.

    import React, { useState, useEffect } from 'react';
    import { StyleSheet, Button, View, Image, Text, ActivityIndicator } from 'react-native';
    import Constants from 'expo-constants';
    import * as ImagePicker from 'expo-image-picker';
    import { gql } from 'apollo-boost';
    import { useMutation } from '@apollo/react-hooks';
    import { ReactNativeFile } from 'apollo-upload-client';
    import * as mime from 'react-native-mime-types';
    
    function generateRNFile(uri, name) {
      return uri ? new ReactNativeFile({
        uri,
        type: mime.lookup(uri) || 'image',
        name,
      }) : null;
    }
    
    const UPLOAD_IMAGE = gql`
      mutation uploadImage($image: Upload) {
        uploadImage(image: $image)
      }
    `;
    
    export default function App() {
    
      const [image, setImage] = useState(null);
      const [status, setStatus] = useState(null);
      const [uploadImage, { data, loading }] = useMutation(UPLOAD_IMAGE);
    
      useEffect(() => {
        (async () => {
          if (Constants.platform.ios) {
            const { status } = await ImagePicker.requestCameraRollPermissionsAsync();
            if (status !== 'granted') {
              alert('Sorry, we need camera roll permissions to make this work!');
            }
          }
        })();
      }, []);
    
      async function pickImage () {
        const result = await ImagePicker.launchImageLibraryAsync({
          allowsEditing: true,
          allowsMultipleSelection: false,
          aspect: [4, 3],
          quality: 1,
        });
    
        if (!result.cancelled) {
          setImage(result.uri);
        }
      };
    
      async function onUploadPress() {
        status && setStatus(null);
        const file = generateRNFile(image, `picture-${Date.now()}`);
        try {
          await uploadImage({
            variables: { image: file },
          });
          setStatus('Uploaded')
        } catch (e) {
          setStatus('Error')
        }
      }
    
      return (
        <View style={styles.container}>
          <Button title="Pick an image from camera roll" onPress={pickImage}/>
          {image && <Image source={{ uri: image }} style={{ width: 200, height: 200 }} />}
          {image && <Button title={ loading ? "Uploading" : "Upload"} onPress={onUploadPress} disabled={loading}/>}
          {
            loading && (
              <ActivityIndicator size="small" style={styles.loading}/>
            )
          }
          <Text style={{ color: status === 'Uploaded' ? 'green' : 'red'}}>{status}</Text>
          {
            status === 'Uploaded' && (
              <Text>URL: {data.uploadImage}</Text>
            )
          }
        </View>
      );
    }
    
    const styles = StyleSheet.create({
      container: {
        flex: 1,
        backgroundColor: '#fff',
        alignItems: 'center',
        justifyContent: 'center',
      },
      loading: {
        margin: 16,
      }
    });
    

    Now, this is a super simple example. You will most likely add more logic to this. Let's say, for example, a feature to let users change their profile picture. You'd need to wait for the Storage Service to give you the picture URL and then you'd modify that user in the database.

    Here's how I'd do it:

    changeUserPicture: async ( 
      _,
      {
        _id,
        picture
      }, {
        User,
        Storage
      }
    ) => {
    
      const user = await User.findOne({ _id }); 
    
      if(user) {
        try {
          const folder = `users/${user._id}/profile/`;
          const { uri } = await Storage.upload(picture, folder);
    
          user.picture = uri;
          const updatedUser = await user.save(); 
    
          return updatedUser;
        } catch(e) {
          console.log(e);
        }
      }
    
      return user;
    
    },
    

    That's pretty much it for today's article! I hope this was useful to you. Feel free to provide any feedback you like or reach out if you need help.

    Once again, here's the repo 포크하려는 경우를 대비하여 소스 코드와 함께.

    읽어 주셔서 감사합니다!

    좋은 웹페이지 즐겨찾기