ActiveStorageを使ってS3に画像を保存する【rails】

はじめに

ActiveStorageを使ってAWSのS3に画像を保存する方法について。

ActiveStorageの設定

まずはActiveStorageをインストールします。

$ rails active_storage:install
$ rails db:migrate

続いて、該当のモデルと関連付けします。

class Article < ApplicationRecord
  has_one_attached :image
end

imageの部分は任意に名前を設定できます。

上記のように設定することで、imageをカラムのように扱うことができます。

具体的には、画像を呼び出す時に下記のような感じになります。

<%= image_tag @article.image %>

ちなみに複数画像を添付したい場合は、has_many_attachedを使用します。

またカラムの名前は複数形にします。

has_many_attached :images

保存先の設定

ActiveStorageの保存先は、デフォルトではローカルのサーバーに保存されます。

config.active_storage.service = :local
config.active_storage.service = :local

localというのは、config/storage.ymlで定義された保存先になります。

local:
  service: Disk
  root: <%= Rails.root.join("storage") %>

保存先をAmazonのS3に変更する

では、本番環境での画像の保存先をAmazonのS3に変更します。

まずはAWSのS3にてバケットを作成します。

続いてIAMユーザーを作成して、S3FullAccessなどのポリシーをアタッチします。

IAMユーザーを作成する際に、アクセスキーIDシークレットアクセスキーというものが作成されるので保存しておきましょう。

保存したIDとキーはcredentials.yml.encなどに環境変数として設定しておきます。

study-output.hatenadiary.com

続いて、Gemをインストールします。

gem "aws-sdk-s3"

続いて保存先をlocalからamazonに変更します。

config.active_storage.service = :amazon

上記で指定した保存先のamazonを定義します。

amazon:
  service: S3
  access_key_id: <%= Rails.application.credentials.aws[:access_key_id] %>
  secret_access_key: <%= Rails.application.credentials.aws[:secret_access_key] %>
  region: <バケットを作成したときのリージョン>
  bucket: <設定したバケット名>

credentials.yml.encを使って環境変数を設定する【rails】

はじめに

railscredentials.yml.encを使って環境変数を設定する方法

credentials.yml.enc とは?

credentials.yml.enc環境変数をまとめておく場所です。

credentials.yml.enc自体は暗号化されていて、master.keyファイルによって復号化されます。

どちらもconfig配下にあります。

設定方法

下記のコマンドを使ってcredentials.yml.encファイルを編集します。

$ EDITOR=vi bundle exec rails credentials:edit

EDITOR=viは編集するエディタを指定しています。

この場合はvimです。

環境変数の設定は下記のようにyaml形式で設定します。

aws:
  access_key_id: aaaaa

secret_access_key: bbbbb

取得方法

続いて、設定した環境変数の取得方法です。

先程設定したaccess_key_idsecret_access_keyの値を取得する場合は、それぞれ下記のようになります。

$ bundle exec rails c

irb(main):001:0> Rails.application.credentials.aws[:access_key_id]
=> aaaaa


irb(main):001:0> Rails.application.credentials.secret_access_key
=> bbbbb

また、本番環境でcredentials.yml.encを使う場合は、下記の設定もしておきます。

config.require_master_key = true
# デフォルトではコメントアウトされている。

上記の設定を行うことで、復号化に必要なmaster.keyファイルがない場合にはエラーが発生するようになります。

railsアプリにGoogleFontsを導入する

はじめに

railsアプリにGoogleFontsの日本語専用のフォントを導入する方法について

設定方法

まずはGoogleFontsの公式サイトに移動します。

googlefonts.github.io

すると下記のようなトップページに遷移すると思います。

Image from Gyazo

すこし下にスクロールすると、日本語版なので数は少ないですが、9種類のフォントの一覧が出てきます。

Image from Gyazo

使いたいフォントをクリックします。

今回は左下のニコモジというフォントを選択します。

すると、画面が下にスクロールされて、下記のような画像になると思います。

Image from Gyazo

続いて、右下の方にあるhtmlの部分をheadタグの中に入れます。

cssの方もコピーして任意のスタイルシートに貼り付けます。

フォントを適用したい文字のクラスに、先程コピーしてきたクラスの部分をあてるとフォントが反映されます。

css

.wf-nicomoji { 
  font-family: "Nico Moji"; 
}

html

<p class="wf-nicomoji">こんにちは</p>

BingNewsSearchAPIを使って特定のニュース一覧を表示する

はじめに

rubyからBingNewsSearchAPIを使って特定のニュース一覧を表示する方法について

前提

・MicrosoftAzureアカウントにログインしている

リソースを作成する

まずAzureアカウントの検索窓でBing Search v7と検索してリソースを作成するページにいきます。

Image from Gyazo

Image from Gyazo

入力項目に任意の値をいれてリソースを作成します。

これでキーとエンドポイントが表示されるので保管しておいてください。

rubyで処理を追加する

require 'net/https'
require 'uri'
require 'json'

accessKey = "先程取得したキーを入力"

uri  = "https://api.bing.microsoft.com/"
path = "/v7.0/news/search"
count = "2"

term = "Microsoft"

uri = URI(uri + path + "?count=" + count + "&q=" + URI.escape(term))

request = Net::HTTP::Get.new(uri)
request['Ocp-Apim-Subscription-Key'] = accessKey

response = Net::HTTP.start(uri.host, uri.port, :use_ssl => uri.scheme == 'https') do |http|
    http.request(request)
end

puts "\nJSON Response:\n\n"
puts JSON::pretty_generate(JSON(response.body))
require 'net/https'
require 'uri'
require 'json'

まずは上記のコードファイルをインポートします。

accessKey = "先程取得したキーを入力"

uri  = "https://api.bing.microsoft.com/"
path = "/v7.0/news/search"
count = "2"

term = "Microsoft"

accessKeyには先程取得したキーを代入します。

uriapiのエンドポイントを代入し、pathにはニュース検索のURLを入れます。

countは記事の取得数を設定します。デフォルトでは10件です。

termは検索する単語を入れます。

uri = URI(uri + path + "?count=" + count + "&q=" + URI.escape(term))

request = Net::HTTP::Get.new(uri)
request['Ocp-Apim-Subscription-Key'] = accessKey

response = Net::HTTP.start(uri.host, uri.port, :use_ssl => uri.scheme == 'https') do |http|
    http.request(request)
end

先程の変数を利用してURLを作成し、apiをたたきます。

puts "\nJSON Response:\n\n"
puts JSON::pretty_generate(JSON(response.body))

帰ってきたJSONのレスポンスを解析して出力しています。

先程のファイルをrubyコマンドで実行します。

$ bundle exec ruby bing_news_search.rb

実際の実行結果は下記のとおりです。

JSON Response:

{
  "_type": "News",
  "readLink": "https://api.bing.microsoft.com/api/v7/news/search?q=Microsoft",
  "queryContext": {
    "originalQuery": "Microsoft",
    "adultIntent": false
  },
  "totalEstimatedMatches": 32,
  "sort": [
    {
      "name": "最も一致する結果",
      "id": "relevance",
      "isSelected": true,
      "url": "https://api.bing.microsoft.com/api/v7/news/search?q=Microsoft"
    },
    {
      "name": "最新",
      "id": "date",
      "isSelected": false,
      "url": "https://api.bing.microsoft.com/api/v7/news/search?q=Microsoft&sortby=date"
    }
  ],
  "value": [
    {
      "name": "AZPower 「Web アプリケーションの Microsoft Azure への最新化」分野で ...",
      "url": "https://japan.cnet.com/release/30569532/",
      "image": {
        "thumbnail": {
          "contentUrl": "https://www.bing.com/th?id=OVFT.9SmEaCzGrZPBD74eFfWwNi&pid=News",
          "width": 600,
          "height": 315
        }
      },
      "description": "「Microsoft Azure への Windows Server と SQL Server の移行」に続いて2つ目となる、Advanced Specializationの取得となります AZPower株式会社(本社:東京都千代田区/代表取締役社長 橋口 信平、以下AZPower)は、2021年7月6日、マイクロソフトがGoldコンピテンシーパートナー向けに高度な専門性を有する企業として「Adv",
      "about": [
        {
          "readLink": "https://api.bing.microsoft.com/api/v7/entities/cf3abf7d-e379-2693-f765-6da6b9fa9149",
          "name": "Windows Azure"
        }
      ],
      "provider": [
        {
          "_type": "Organization",
          "name": "CNET",
          "image": {
            "thumbnail": {
              "contentUrl": "https://www.bing.com/th?id=AR_813fbe5ed7179eb7f517fd9bab6522f9&pid=news"
            }
          }
        }
      ],
      "datePublished": "2021-07-15T00:52:00.0000000Z",
      "category": "ScienceAndTechnology"
    },
    {
      "name": "マイクロソフトがWindows 365を公開-コンピューティングの新しい ...",
      "url": "https://www.sanspo.com/geino/news/20210715/prl21071510170047-n1.html",
      "image": {
        "thumbnail": {
          "contentUrl": "https://www.bing.com/th?id=OVFT.s9nOmSHw4aHPf68X2QpXpy&pid=News",
          "width": 700,
          "height": 367
        }
      },
      "description": "マイクロソフト コーポレーション(Microsoft Corp.)は15日、Windows 10ないしはWindows 11(利用可能になった場合)を体験する新しい方法をあらゆる規模の企業に導入するクラウドサービスWindows 365を発表した。Windows 365は、オペレーティングシステム(OS)をマイクロソフトクラウドに取り込み、アプリ、データ、セッティングのすべてのWindows体験を",
      "about": [
        {
          "readLink": "https://api.bing.microsoft.com/api/v7/entities/16aeb6d9-9098-0a40-4970-8e46a4fcee12",
          "name": "Microsoft Windows"
        }
      ],
      "provider": [
        {
          "_type": "Organization",
          "name": "SANSPO"
        }
      ],
      "datePublished": "2021-07-15T01:17:00.0000000Z",
      "category": "ScienceAndTechnology"
    }
  ]
}

公式ドキュメント

docs.microsoft.com

【jQuery】添付した画像をプレビュー表示する

はじめに

jqueryを使って、添付した画像をプレビューとして表示する方法について。

画像アップロード画面

画像アップロード画面のビューは下記の通りです。

  <%= form_with url: image_search_bugs_path, method: :post, local: true do |f| %>
    <div class="form-group mb-3">
      <p style="color: red;">※ファイル形式は`png`か`jpeg`でお願いします。</p>
      <%= f.file_field :image, class: 'form-control', id: 'input_image', accept: 'image/png, image/jpeg' %>
    </div>
    <div class="mb-3">
      <%= image_tag '', id: 'image_preview', style: 'display: none;' %>
    </div>
    <div class="actions mb-3">
      <%= f.submit '検索する', class: 'btn btn-primary' %>
    </div>
  <% end %>

画像ファイルをアップロードするタグに、input_imageというidを付与しています。

画像のプレビュー画面を表示するタグには、image_previewというidと、display: noneをつけてデフォルトでは見えない状態にしています。

jQueryを追加

続いて、jqueryの記述は下記のとおりです。

document.addEventListener("turbolinks:load", function() {
  $(function(){
    $("#input_image").change(function(){
      var file = $(this).prop('files')[0];
      var reader = new FileReader

      reader.onload = (function(){
        $("#image_preview").css("display", "");
        $("#image_preview").css({"width":"300px", "height":"300px"});
        $("#image_preview").attr("src", reader.result);
      });
      reader.readAsDataURL(file);
    })
  })
})

idのinput_imageが変わった時、つまり画像がアップロードされたときにイベントが発火します。

      var file = $(this).prop('files')[0];
      var reader = new FileReader

アップロードされた画像のデータを取得して、変数fileに代入します。

FileReaderインスタンスを作成して、変数readerに代入します。

      reader.onload = (function(){
        .....
      });
      reader.readAsDataURL(file);

readAsDataURLを使って先程取得した画像のデータを変数readerに読み込ませます。

reader.onloadによって画像の読み込みが完了してから、内部の関数が実行されます。

      reader.onload = (function(){
        $("#image_preview").css("display", "");
        $("#image_preview").css({"width":"300px", "height":"300px"});
        $("#image_preview").attr("src", reader.result);
      });
      reader.readAsDataURL(file);

idimage_preview、つまりプレビューが表示されるimgタグに対してcssメソッドを使い、付与されているdisplayプロパティを削除します。

続いて、画像の大きさを指定するプロパティを追加します。

attrメソッドを使用して、src属性と取得した画像のurlを値として付与します。

完成

Image from Gyazo

AmazonRekognitionを使って画像検索機能を作る

はじめに

はじめて外部APIを使ってみたのでその内容を書きます。

順序

1:画像を送信する。

2:送信された画像をawss3を使って、bucketを指定して保存する。

3:rubyからAmazon RekognitonAPIを叩いて、先程保存した画像を画像認識して、ラベルを取得する。

4:取得したラベルをAmazon Translateを使って日本語翻訳して、そのワードを元にフリーワード検索をしてデータを取得する。

awsのIAMユーザーの作成

まず、awsIAMにアクセスしてユーザーを作成します。

その際にAccess key IDSecret access keyが作成されるので、保管して置いてください。

また、Amazon s3Amazon RekognitonAmazon Translateを使用するので、それらのアクセス許可のポリシーをアタッチしてください。

詳しい方法についてはドキュメントを参照してください。

docs.aws.amazon.com

docs.aws.amazon.com

docs.aws.amazon.com

aws s3のバケットを作成

続いて、s3にアクセスしてバケットを作成します。

やることとしては、任意のバケット名をつけることと、リージョンを設定するだけです。

詳しくはドキュメントを参照してください。

docs.aws.amazon.com

環境変数を設定

先程保管しておいたしておいた、Access key IDSecret access key環境変数に設定します。

export AWS_ACCESS_KEY_ID=<取得したAccess key ID>
export AWS_SECRET_ACCESS_KEY=<取得したSecret access key>

ここまでの設定が終わったら、本題の画像検索機能を実装していきます。

Gemのインストール

まずは下記のgemをインストールします。

gem 'aws-sdk-s3'
gem 'aws-sdk-rekognition'
gem 'aws-sdk-translate'

画像のアップロード画面を作成

続いて画像入力のビューを作ります。

※active storage使ってます

      <%= form_with url: search_images_path, scope: :search, method: :post, local: true do |f| %>
        <div class="form-group mb-3">
          <%= f.label :search_image %>
          <%= f.file_field :image, class: 'form-control' %>
        </div>
        <div class="actions mb-3">
          <%= f.submit '検索する', class: 'btn btn-primary' %>
        </div>
      <% end %>

画像をs3に保存する

続いて、取得した画像をs3に保存します。

  def upload_image(data)
    credentials = Aws::Credentials.new(
      ENV['AWS_ACCESS_KEY_ID'],
      ENV['AWS_SECRET_ACCESS_KEY']
    )
    region = 'us-east-1'

    # アップロードされた画像をs3に保存
    s3_client = Aws::S3::Client.new(region: region, credentials: credentials)

    body = data
    bucket = 'article-image-search'
    key = "article_image"

    s3_client.put_object({
      body: body,
      bucket: bucket,
      key: key
    })

    put_labels(key, s3_client)
  end

upload_imageというメソッドを作って、画像のデータを引数として受け取ります。

    credentials = Aws::Credentials.new(
      ENV['AWS_ACCESS_KEY_ID'],
      ENV['AWS_SECRET_ACCESS_KEY']
    )
    region = 'us-east-1'

credentialsregionを定義します。

regions3バケットを作成したときに設定したものを指定します。

    s3_client = Aws::S3::Client.new(region: region, credentials: credentials)

    body = data
    bucket = 'article-image-search'
    key = "article_image"

    s3_client.put_object({
      body: body,
      bucket: bucket,
      key: key
    })

    put_labels(key, s3_client, credentials, bucket)

bodyに引数で受け取った画像データを代入します。

bucketには先程作成したバケット名を定義します。

keyには任意の値をしていします。

put_objectメソッドを使用して、s3に画像を保存します。

最後にこの後作成するput_labelsというメソッドを呼び出します。

画像のラベルを検出する

  def put_labels(key, s3_client, credentials, bucket)
    rekogniton_client   = Aws::Rekognition::Client.new(credentials: credentials)
    attrs = {
      image: {
        s3_object: {
          bucket: bucket,
          name: key
        },
      },
      max_labels: 10
    }

    response = rekogniton_client.detect_labels attrs
    label_list = []
    response.labels.each do |label|
      label_list << label.name.downcase
    end

    s3_client.delete_objects({
      bucket: bucket,
      delete: {
        objects: [
          {
            key: key
          },
        ],
        quiet: false
      }
    })

    translate_label(label_list)
    rekogniton_client   = Aws::Rekognition::Client.new(credentials: credentials)
    attrs = {
      image: {
        s3_object: {
          bucket: bucket,
          name: key
        },
      },
      max_labels: 10
    }

max_labelsで検出するラベルの上限を指定できます。

    response = rekogniton_client.detect_labels attrs
    label_list = []
    response.labels.each do |label|
      label_list << label.name.downcase

detect_labelsを使って画像を解析したデータを取得して、

ラベル名だけを配列に入れます。

このとき、ラベル名の頭文字が大文字になっていることがあるので、

downcaseメソッドを使ってすべて小文字に変換します。

これをしないと、次で説明する日本語翻訳がうまくいきません。

    s3_client.delete_objects({
      bucket: bucket,
      delete: {
        objects: [
          {
            key: key
          },
        ],
        quiet: false
      }
    })

    translate_label(label_list)

delete_objectsメソッドを使って、画像認識に使用した画像を削除します。

最後に次で作成するtranslate_labelという日本語翻訳するためのメソッドを呼び出します。

ラベル名を翻訳する

  def translate_label(label_list)
    region = 'us-east-1'

    # ラベルを翻訳
    translate_client = Aws::Translate::Client.new(
      region: region,
      credentials: credentials,
    )

    label_list_ja = []

    label_list.each do |label|
      res = translate_client.translate_text({
              text: label,
              source_language_code: 'auto',
              target_language_code: 'ja'
            })
      label_list_ja << res.translated_text
    end

    # ラベルのリストを返す
    label_list_ja
  end
    label_list.each do |label|
      res = translate_client.translate_text({
              text: label,
              source_language_code: 'auto',
              target_language_code: 'ja'
            })
      label_list_ja << res.translated_text
    end

translate_textメソッドを使用して先程のラベル名を翻訳します。

textには翻訳したいテキストを指定します。

source_language_codeには翻訳前の言語を指定します。

autoを指定するとapi側がよしなに指定してくれます。

target_language_codeには、翻訳後の言語を指定します。

翻訳したラベルのリストをlabel_list_jaに代入して値を返します。

あいまい検索のメソッドを作成

続いて、先程翻訳したラベルの配列を使って、あいまい検索をします。

  def label_search(label_list)
    relation = Bug.distinct

    result = []

    label_list.each do |label|
      relation.where('name LIKE ?', "%#{label}%")
              .or(relation.where('feature LIKE ?', "%#{label}%"))
              .or(relation.where('approach LIKE ?', "%#{label}%"))
              .or(relation.where('prevention LIKE ?', "%#{label}%"))
              .or(relation.where('harm LIKE ?', "%#{label}%"))
              .each { |bug| result << bug }
    end
    result.uniq!
  end

orメソッドを使ってor検索をしています。

取得したレコードを配列にいれ、uniq!メソッドを使って重複したレコードを削除します。

コントローラを作成

続いて、コントローラに下記のコードを追加します。

class SearchImagesController < ApplicationController
  include SearchImagesHelper

  def new; end

  def create
    data = params[:search][:image]
    label_list = upload_image(data)
    bug_array = label_search(label_list)
    if bug_array.present?
      @bugs = Bug.where(name: bug_array.map(&:name)).page(params[:page])
    else
      @bugs = Bug.none.page(params[:page])
    end
    render 'bugs/index'
  end
end

先程のヘルパーをインクルードします。

    data = params[:search][:image]
    label_list = upload_image(data)

paramsで画像データを取得してdata変数に代入し、それを引数にして先程作成したupload_imageメソッドを呼び出します。

帰ってきたラベルの配列データをlabel_listに代入します。

    bug_array = label_search(label_list)

先程作成したlabel_searchを使って、レコードの配列を取得します。

    if bug_array.present?
      @bugs = Bug.where(name: bug_array.map(&:name)).page(params[:page])
    else
      @bugs = Bug.none.page(params[:page])
    end
    render 'bugs/index'

kaminariというページネーションのgemで使用できるようになるpageメソッドを使う際に、

Arrayクラスでは使用できないため、whereメソッドとnoneメソッドを使って無理やりActiveRecord::Relationクラスに変更させます。

最後に

もっといい方法があればコメント等ください!

参考

qiita.com

docs.aws.amazon.com

docs.aws.amazon.com

docs.aws.amazon.com

【vue.js】namespacedを使ってvuexをモジュール化する

はじめに

namespacedを使ってvuexをモジュール化する方法について

前提

現在、vuexストアの構成はapp/javascript/store/index.jsファイルがあるだけで、そこにタスク関連の状態管理を記述しています。

今後ユーザーなどの状態管理もするために、設定ファイルをモジュール化して管理しようと思います。

具体的には、新たにapp/javascript/store/modules/tasks.jsファイルとapp/javascript/store/modules/users.jsファイルを作成し、

そのファイルにそれぞれの状態管理を記述し、app/javascript/store/index.jsにインポートするようにします。

store/modules/tasks.jsファイルを作成

下記コマンドでapp/javascript/store/modules/tasks.jsファイルを作成します。

$ touch app/javascript/store/modules/tasks.js

app/javascript/store/index.jsファイルに記述していたタスク関連の状態管理のコードを、app/javascript/store/modules/tasks.jsファイルに移動して、エクスポートします。

その際namespacedを使って管理しやすくします。

import axios from '../../plugins/axios'

const state = {
  tasks: []
}

const getters =  {
  tasks: state => state.tasks
}

const mutations = {
  setTasks: (state, tasks) => {
    state.tasks = tasks
  },
  addTask: (state, task) => {
    state.tasks.push(task)
  },
  deleteTask: (state, deleteTask) => {
    state.tasks = state.tasks.filter(task => {
      return task.id != deleteTask.id
    })
  },
  updateTask: (state, updateTask) => {
    const index = state.tasks.findIndex(task => {
      return task.id == updateTask.id
    })
    state.tasks.splice(index, 1, updateTask)
  },
}

const actions = {
  fetchTasks({ commit }) {
    axios.get('tasks')
      .then(res => {
        commit('setTasks', res.data)
      })
      .catch(err => console.log(err.response));
  },
  createTask({ commit }, task) {
    return axios.post('tasks', task)
      .then(res => {
        commit('addTask', res.data)
      })
  },
  deleteTask({commit}, task) {
    return axios.delete(`tasks/${task.id}`)
      .then(res => {
        commit('deleteTask', res.data)
      })
  },
  updateTask({commit}, task) {
    return axios.patch(`tasks/${task.id}`, task)
      .then(res => {
        commit('updateTask', res.data)
      })
  }
}

export default {
  namespaced: true,
  state,
  getters,
  mutations,
  actions
}

store/modules/users.jsファイルを作成

app/javascript/store/modules/users.jsファイルを作成します。

$ touch app/javascript/store/modules/users.js

ユーザー関連の状態管理の記述は今後作成します。

const state = {
}

const getters =  {
}

const mutations = {
}

const actions = {
}

export default {
  namespaced: true,
  state,
  getters,
  mutations,
  actions
}

store/index.jsファイルを修正

最後にapp/javascript/store/index.jsファイルに、先程作成したファイルをインポートします。

import Vue from 'vue'
import Vuex from 'vuex'
import tasks from './modules/tasks'
import users from './modules/tasks'

Vue.use(Vuex)

export default new Vuex.Store({
  modules: {
    tasks,
    users
  }
})

上記のように、インポートしたストアをmodulesというキーに対して割り当てることで、vuexのモジュール化ができます。

vuexのデータをvue側で呼び出す

<template>
........
........
</template>

<script>
import { mapGetters, mapActions } from 'vuex'
........
........
export default {
.......
.......
  computed: {
    ...mapGetters('tasks', [ 'tasks' ]),
    // ...mapGetters([ 'tasks' ]) ← namespaced していない時
.......
.......
  created() {
    this.fetchTasks();
  },
......
......
  methods: {
    ...mapActions('tasks', [ 'fetchTasks', 'createTask', 'updateTask', 'deleteTask' ]),
  //  ...mapActions([ 'fetchTasks', 'createTask', 'updateTask', 'deleteTask' ])  ← namespaced していない時

タスクの状態管理のストアは、namespacedを使ってネストしているため、その中のgetteractionsを呼び出す時も、

明示的にネストしていることを記述する必要があります。