SFTP To Goを使ってFTPからShopifyのファイルにインポートする方法

(ゲスト投稿:Eshop Guide社 CTO、Dave Crowder 著書)

本記事では、Ruby、Heroku、SFTP To Goを使って、Shopifyのファイルインポートプロセスの自動化を実現するために必要な手順についてお話します。

今や誰でも、オンラインショップですら自分で簡単にファイル同期を行うことができますが、我々プログラマーに任せておいた方が良いこともまだあります。

最近、消費者に直接商品を販売するビジネスの多くが、Shopifyを使用しています。Shopifyは使いやすさ、デザインの美しさの面、また在庫管理から注文管理、決済処理まで、ほぼすべての管理を代行してくれます。

また、外部プラットフォームとの連携によって、ストアを目立たせ、トラフィックを獲得することができます。

Shopifyでは、顧客に[購入]ボタン以上のものを提供し、最初から最後までストアを通じてブランドを表現できるようにするために、新しいコンセプトや技術的スキルに対応し続ける方法が提供されています。画像、仕様書、マニュアル、製品の特徴を示す視覚的な手がかりなどの追加情報によって、製品を充実させるチャンスがありますよ。

Shopify内では、このようなドキュメントの保存場所はストアファイルが最適です。( Shopify の管理画面からサインインして閲覧することもできます)

これは、Shopifyのテンプレート言語であるLiquidを介してショップテーマから簡単にアクセスすることで、ShopifyのグローバルCDN (コンテンツデリバリーネットワーク)のスピードと安定性のメリットを受けています。実は、2021年6月のShopify APIのアップデートまで、ストアのファイルの管理は面倒な手作業でしたが、アップデートによってストアファイルは GraphQL APIを使ってプログラムで作成できるようになりました。ただ、これには後ほど紹介するような制約があります。

実現したかったこと

私たちの顧客であるショップオーナーは、Shopifyストアに簡単な同期オプションやファイルのアップロードを追加する必要がありました。

ShopifyのGraphQLでこのようなオプションが提供されていることは間違いありませんが、このプラットフォームは開発者向けに構築されているため、エンドユーザーやIT担当者には少し使いづらいかもしれません。一方、FTPは古くから存在するプロトコルで、大多数の IT 管理者のツールベルトとして知られており、タスクの自動化やFTP サーバーとの間でファイルをプッシュするなどのプロセスで構成されています。

そこで、PIM (製品情報管理システム)などからアイコン、画像、ユーザー マニュアルなどをエクスポートし、Shopifyストアからアクセスできる便利な方法を提供することにしました。

SFTP To Goを選んだ理由

2015年に初めてRailsアプリケーションを書いて以来、Herokuはコードを本番環境にデプロイする最もシンプルな方法であり、インフラについてほとんど考える必要がないため、筆者にとって本当に重要なこと、つまりコードによる価値の提供により集中できると感じています。

また、FTPインスタンスを設定して、その状態やストレージの監視や、アプリの環境変数へのFTP URLの追加などを気にしないといけない代わりに、HerokuはSFTP To Goのようなサービスのアドオンによって簡単に拡張もできます。数回のクリックだけで、完全に管理され、すぐに使えるFTPサーバーをアプリケーションに追加することができるのです!

では、ファイルはどのようにしてShopifyに取り込まれるのか

ShopifyにはFiles APIを使ってファイルをアップロードする方法は2通りあります。実際、どちらも2段階のプロセスとなります。この例で使わなかった方は、stagedUpload Target を作成し、そのURLにファイルをアップロードする必要があります。もう1つの方はもう少しシンプルで、オリジナルソース(外部の公開 URL)を指定し、ShopifyがそのURL からファイルを読み出す(フェッチ)ようにします。この方法では、アップロードプロセスを行う必要はありませんが、FTPサーバーに置かれたファイルを提供するのに、Webサーバーが必要です。

幸いなことに、SFTP To Goは、/publicという特別なフォルダから公開されたファイルもサポートしています。プロセスを簡単に表すと、下の画像ようなプロセスとなります。

さて、いよいよコードについて解説していきます。

FileOrganizerService

これは、その名が示すように、全部の糸を一緒に引っ張るものです。また、Cron To Goという Heroku のアドオンを使って、1日に1回、またはその他のスケジュールで実行するようにスケジュールされた 「rake」 タスクから呼び出されるファイルのインポート処理のエントリポイントにもなります。

まず、重複したアップロードを避けるために、Shopifyストアに既存のファイル のリストを取得します。次に、SftpFileLoaderService を呼び出して、FTPサーバー上の指定されたフォルダからファイル名を全て読み込みます。最後に、そのファイル名を使って、FileImporterServiceを使ってShopifyに新しいファイルエントリを作成します。

module FileServices
 ## reads existing filenames from Shopify to avoid duplicates
 # then starts reading filenames from FTP and creates new file entries in Shopify
 class FileOrganizerService < ApplicationService
   attr_reader :shop

   def initialize(shop)
     @shop = shop
   end

   def call
     # get an array of the existing filenames in the Shopify store to avoid duplicate uploads
     existing_filenames = query_existing_files
     # get all filenames that are not yet existing in the shopify files
     file_names = FileServices::SftpFileLoaderService.new(ENV.fetch('SFTPTOGO_URL'), existing_filenames).call
     file_names.each do |file_name|
       # Create a new file entry
       FileServices::FileImporterService.new(shop).call(file_name)
     rescue StandardError => e
       # report any errors to everyone's favourite error tracker
       Honeybadger.notify("ERROR Uploading #{file_name}: #{e.message}")
     end
   end

   private

   def query_existing_files
     existing_filenames = []
     shop.with_shopify_session do
       file_result = ShopifyAPI::GraphQL.client.query(file_search_query)
       existing_filenames << query_result_to_array(file_result&.data&.files&.edges)
       # if more than one page
       while file_result&.data&.files&.page_info&.has_next_page
         cursor = file_result&.data&.files&.edges&.last&.cursor
         file_result = ShopifyAPI::GraphQL.client.query(file_search_query, variables: { cursor: cursor })
         existing_filenames << query_result_to_array(file_result&.data&.files&.edges)
       end
     end
     existing_filenames.flatten
   end

   def file_search_query
     ShopifyAPI::GraphQL.client.parse <<-'GRAPHQL'
       query ($cursor: String){
         files(first:250, after: $cursor){
           pageInfo {
                 hasNextPage
               }
           edges{
             cursor
             node{... on GenericFile {
                 url
                 id
             }}
             node{... on MediaImage {
                 id
             }}
           }
         }
       }
     GRAPHQL
   end

   private
   def query_result_to_array(edges)
     return unless edges.present?

     edges.map do |edge|
       if edge&.node&.__typename == 'GenericFile'
         File.basename(URI.parse(edge&.node&.url).path) if edge&.node&.url.present?
       else
         edge&.node&.id
       end
     end
   end


 end
end
FileOrganizerService.rb

SftpFileLoaderService

このサービスは、SFTPサーバーへの接続を処理します。HerokuアプリケーションにSFTP To Goをインストールすると、SFTPTOGO_URL という環境変数が自動的に作成されます。この URL を使って、このサービスを呼び出します: ENV.fetch('SFTPTOGO_URL')

ENV.fetch('SFTPTOGO_URL')

もう一つの ENV変数 FTP_FILE_DIR は、新しいファイルを探すためのディレクトリを指定します。筆者たちの場合、これは公開フォルダのサブフォルダで、HTTPSで利用可能です(これは、プロセスの後半で Shopifyがそのファイルをピックアップするときに違いが出ます)。

最終的にこの処理は、指定されたディレクトリにある、Shopify にまだないファイル名のリストを返します。

module FileServices
 class SftpFileLoaderService < ApplicationService
   attr_reader :sftp_uri, :filename, :existing_filenames

   def initialize(sftp_uri, existing_filenames)
     @sftp_uri = URI(sftp_uri)
     @existing_filenames = existing_filenames
   end

   def call
     load_files
   end

   private

   def sftp
     # establish connection to SFTP Server
     @sftp ||= Net::SFTP.start(@sftp_uri.host, @sftp_uri.user, password: @sftp_uri.password)
   end

   # fetches all filenames from the configured directory, that are not in the existing_filenames list
   def load_files
     file_names = []
     dir = ENV.fetch('FTP_FILE_DIR')
     sftp.dir.foreach(dir) do |entry|
       # skip if file already exists on server
       next if existing_filenames.include? entry.name
       file_names << entry.name
     rescue Net::SFTP::StatusException => e
       Rails.logger.error "Error while downloading data: #{e.description}"
     end
     file_names
   end
 end
end
SftpFileLoaderService.rb

FileImporterService

この処理は、FileImporterServiceを実行する前に返された各ファイル名に対して行われます。

ここで覚えておくべきポイントは、「full_url」です。これが「URL-Escaped」ファイル名に加えて、ENV変数 SFTPTOGO_PUBLIC_URL で満たされます。こうすることで、ファイルは一般に公開され、Shopifyでダウンロードできるようになります。

module FileServices
 class FileImporterService < ApplicationService
   attr_reader :shop

   class FileCreateError < StandardError
   end

   def initialize(shop)
     @shop = shop
   end

   def call(file_name)
     shop.with_shopify_session do
       execute_query(file_name)
     end
     true
   end

   private

   def execute_query(file_name)
     full_url = "#{ENV.fetch('SFTPTOGO_PUBLIC_URL')}#{ERB::Util.url_encode(file_name)}"
     Rails.logger.info "Creating #{file_name}"
     result = ShopifyAPI::GraphQL.client.query(file_create_query, variables: {
                                                 "files": {
                                                   "originalSource": full_url
                                                 }
                                               })
     error = result&.errors&.details || result.to_h['data']['fileCreate']['userErrors'].first

     raise FileCreateError, error['message'] if error.present?
   end

   def file_create_query
     ShopifyAPI::GraphQL.client.parse <<-'GRAPHQL'
     mutation ($files: [FileCreateInput!]!) {
       fileCreate(files: $files) {
         files {
           fileStatus
         }
         userErrors {
           field
           message
         }
       }
     }
     GRAPHQL
   end
 end
end
FileImporterService.rb

これらはShopifyストアにファイルをインポートするときの簡単なプロセスです。マーチャントはファイルをFTPディレクトリに置くだけで、あとは何も要りません。SFTP To GoにWebフック通知があるので、スケジューラーの代わりに、ファイルがアップロードされたらすぐに同期したり、Shopify からファイルを外すことができるでしょう。

著者について

Dave Crowder, Eshop Guide CTO

Daveは、Eshop Guideの創業メンバー兼CTOです。ドイツを拠点に小規模なスタートアップから大企業まで、さまざまなクライアントにShopifyに関するプロジェクトやサービスを提供しています。Eshop Guideは Shopifyの公開アプリでも活躍しており、これまでlexofficesevDeskなどのドイツでの会計システムや、idealoなどの価格比較ポータルサイト向けの統合アプリを複数開発しています。

革新的な企業であるEshop Guideは、代理店として働くことの意味について、独自の視点を持っていると自負しています。従業員のストレスを軽減し、身体的・メンタル的にも健康であることを最優先させることで、日々の業務に取り組むことで、最終的に生産性が上がると考えています。

また、彼自身も瞑想を日課としており、瞑想を通じて意識を高めることに力を入れています。これがEshop Guideの職場環境にも反映されています。