【個人開発】カメラの設定値を共有するカメラ初心者向けサービス「ShotSharing」をリリースしました

## はじめに

こんにちは、@crew_runteq38と申します。
SES企業からWebエンジニア転職を目指しております。
プログラミング学習中の身であるため、技術的な内容に誤りを含んでいる可能性があります。
おかしな記述がありましたらコメント等で教えていただけたらと思います:bow:

## 作成したサービス

サービス名：[Shot Sharing](https://shotsharing.vercel.app/)

![og-image.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1865715/fc16a7da-7dfe-52dd-8d0c-fe4108894a54.jpeg)


- サービスURL : https://shotsharing.vercel.app/

- Github : https://github.com/Kaiwa-Jun/shotsharing

## 開発の背景

私の趣味は一眼レフカメラで写真を撮るとこなのですが、一眼レフカメラを趣味にしている方の中で、**カメラの設定をするのが難しいと感じたとこありませんか？**

私も今でこそ撮影時のカメラの設定を自分で変えられるようになりましたが、カメラを買いたての頃は「ISO？ F値？ シャッタースピード？」と、撮影シーンごとの適切な設定方法がわかりませんでした。

そこで、カメラ初心者やこれからカメラを趣味にしたいけど難しそうと思っている方でもカメラの設定ができるように「ShotSharing」を開発しました。

「ShotSharing」は画像を投稿すると、その画像を撮影した際のカメラの設定値も一緒に投稿できるサービスです。


## 実装機能

ShotSharingは誰でも使い始められるように設計しました。
以下実装機能になります。

### Googleログイン

![ログイン.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1865715/a723796e-b6f7-00fe-21af-80c9e9cf6132.png)


未ログイン状態でも投稿の閲覧、検索はできますが、投稿をしたり、いいね・コメントなどの機能を使うにはログインが必要です。
ユーザビリティの低下を防ぐためにGoogleログインを採用しています。

### 画像の投稿（ログインユーザーのみ）

![投稿.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1865715/890100a9-86ab-546d-6a5d-a422edad898a.png)


ヘッダーの+アイコンから画像の投稿ができます。
撮影した画像を選択して投稿すると一覧画面に遷移し、撮影時のカメラの設定が表示されます。

### 投稿の検索

![検索.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1865715/d6432768-ba4b-4af5-4ae5-64301fb5b63c.png)


ヘッダーの🔍アイコンから投稿の検索ができます。

### いいね（ログインユーザーのみ）

![投稿のいいね.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1865715/5d2339f2-3e91-b6e6-48b0-9797c7ae4864.png)


各投稿のいいねアイコンをタップすることでいいねができます。
マイページから自分がいいねした投稿を参照することもできます。

### コメント（ログインユーザーのみ）

各投稿のコメントアイコンをタップすると、コメント入力ページに遷移し、コメントができます。
マイページから自分がコメントした投稿を参照することもできます。

### Twitter共有

画像投稿後にTwitterでの共有ができます。

<table>
<tr>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1865715/7c7e8fac-97b4-1ae5-c1b4-316ba2d9467a.png"></td>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1865715/6942edf5-acc4-9efc-bd9a-1164cba6ef9d.png"></td>
</tr>
</table>


### プロフィールの編集

![プロフィール編集.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1865715/d577a511-3553-b0d4-146b-a640837cd17f.png)


マイページからプロフィールの編集ができます。
変更できる項目は、ユーザー名とアバターアイコンになります。

## 使用技術について
主な使用技術は以下の通りです。

### バックエンド

- Rails 6.1.7（APIモード）
- Ruby 3.0.5

### 主に使用したGem

- pg
- puma
- puma_worker_killer
- omniauth-google-outh2
- exifer
- rack-cors
- aws-sdk-s3
- google-id-token
- firebase_id_token
- jwt
- google-cloud-vision
- google-cloud-translate

### 技術選定理由

- PostgreSQL
    - MySQLやSQLiteと比較して、高度なデータ型、ストアドプロシージャ、トリガー、ビューなどの機能を備えており、拡張性も非常に高い。また、ACID(Atomicity, Consistency, Isolation, Durability)特性をサポートしており信頼性が高い
- Puma
    - PumaはRailsのWebサーバーの一つで、リクエストを並行処理するために使用されます
    - WEBrickやUnicorn、Thinなどと比較して、スレッドとワーカーの両方を用いることができ、リソースを効率的に活用しながら高いパフォーマンスが期待で知る
- google-id-token、firebase_id_token、jwt
    - GoogleログインとJWTによるセッション管理のために採用しました
    - OAuth 2.0に基づく認証としては、DoorkeeperやOmniAuthなどのGemがありますが、これらは認証情報の取り扱いに手間がかかり、誤った実装によりセキュリティの問題を引き起こす可能性があります
    - それに対し、**`google-id-token`**と**`firebase_id_token`**はGoogleから提供されたIDトークンの検証を簡単に行うことができます
- Exifr
    - 類似のGemにMini Exiftool、Exifがありましたが、Exifrは表示したいパラメータが揃っていたため採用しました
- Google Cloud Vision API
    - 投稿された画像のラベリングを行い、検索機能に利用しています
    - 他のGoogleサービス（今回の場合はFirebase AuthenticationやGoogleログイン）との親和性が高く、一貫したセキュリティと使いやすさがある
- Google Cloud Translate API
    - Google Cloud Vision APIで得られた英語のラベルを日本語に翻訳し、日本語で検索できるようにしています
    - 他のGoogleサービス（今回の場合はFirebase AuthenticationやGoogleログイン）との親和性が高く、一貫したセキュリティと使いやすさがある

### フロントエンド

- React 18.2.0
- TypeScript 5.0.2
- TailwindCSS 3.2.7
- DaisyUI 3.2.1

### 主に使用したライブラリ

- react-router-dom 6.14.2
- axios 1.4.0
- eslint 8.36.0
- flowbite 1.7.0
- firebase-admin 11.10.1
- @firebase/auth 1.0.0
- @google-cloud/vision 3.1.4
- next-seo 6.1.0

### 技術選定理由

- React、TypeScript
    - Vue.jsやAngularなど他のフレームワークと比較して、使用者が多く参考になるドキュメントが豊富であることから採用しました
    - TypeScriptは開発中のエラー検出で、効率的なコード管理をするために採用しました
- TailwindCSS
    - TailwindCSSはユーティリティファーストのCSSフレームワーク
    - 個々のスタイルを直接HTMLに適用することで、柔軟かつ迅速なUIのデザインを可能
    - Bootstrapなど他のCSSフレームワークと比較して、TailwindCSSはカスタムデザインの自由度が高く、冗長なCSSを削減できる
- DaisyUI
    - TailwindCSSに対するプラグインで、再利用可能なコンポーネントのセットを提供できる
    - UIの一貫性を保ちつつ開発速度を向上させることが可能
- Flowbite
    - Tailwind CSSで動作するコンポーネントライブラリ
    - フロントエンド開発の迅速化と一貫性のあるUIを採用できる
- ESlint
    - コードの品質、一貫性を担保するために採用
- Firebase Admin SDK ( firebase-admin )／Firebase Authentication ( firebase-admin )
    - Googleログインを実装するために採用
    - Firebaseは以前に使用したことがあり、学習コストが少なく、実装期間の短縮のために採用
- next-seo
    - Next.jsプロジェクトのSEO（検索エンジン最適化）を管理するためのライブラリ
    - コンポーネントベースでSEO設定を制御できるため、ページごとのメタタグ管理が容易

### インフラ

インフラ構成、開発環境は以下の通りになっています。

- インフラ
    - heroku
    - vercel
    - AWS S3
- 開発環境
    - Docker
    - docker-compose（Rails, Redis, PostgreSQL）
- その他
    - Firebase Authentication
    - Google Cloud Vision API
    - Google Cloud Translate API
    

### 技術選定理由

- Heroku
    - データベースの設定、環境変数の管理、ログの確認などが直感的な操作で可能
    - ダイノのスケールアウトを容易に行え、アプリケーションの負荷に応じてリソースを調整することが可能
- Vercel
    - ReactやNext.jsのようなJavaScriptベースのフロントエンドフレームワークをホスティングするのに優れている
    - Next.jsの開発元であるため、最新のNext.jsの機能をすぐに反映でき、Next.jsに特化した最適化やサーバーサイドレンダリング(SSR)の利用が可能
- AWS S3
    - 以前に使用したことがあり、学習コストが少なく、実装期間の短縮のために採用しました
- Docker、docker-compose（Rails, Redis, PostgreSQL）
    - 開発環境を統一し、本番環境と開発環境の間での違いを最小限に抑えることが可能
    - 他の仮想化技術と比較して軽量で高速
    - docker-composeによって、複数のコンテナ（この場合、Railsアプリケーション、Redis、PostgreSQL）を一緒に管理できる
- Firebase Authentication
    - Firebase Authenticationはユーザー認証を簡単に行うことができる
    - 自前で認証システムを開発・管理すると、セキュリティ対策やユーザーデータのプライバシー保護など、多くの負担が伴う
    - これらの課題をFirebaseに委ね、安全にユーザー認証を行うことが可能
    - Googleログインなどのソーシャルログインを簡単に導入できる


## 工夫した点

### Exifrでの画像情報の取得・表示

投稿画像のExifデータの取得に[exifr](https://github.com/remvee/exifr)を使用しました。

```ruby
require 'exifr/jpeg'

class Api::V1::PhotosController < ApplicationController
  def create
    # アップロードされた画像を取得
    uploaded_image = params[:image]

    # 画像がアップロードされていない場合はエラーを返す
    if uploaded_image.nil?
      return render json: { errors: "No image uploaded" }, status: :unprocessable_entity
    end

    # 画像がJPEG形式である場合のみExifデータを取得
    uploaded_image_io = uploaded_image.open
    if uploaded_image.content_type == "image/jpeg"
      # Exifデータを取得
      exif_data = EXIFR::JPEG.new(uploaded_image_io)

      # Exifデータから各種情報を取得
      iso = exif_data.iso_speed_ratings
      shutter_speed = exif_data.shutter_speed_value.to_s
      f_value = exif_data.f_number.to_f
      camera_model = exif_data.model
      taken_at = exif_data.date_time_original
      latitude = exif_data.gps_latitude
      longitude = exif_data.gps_longitude
      exposure_time = exif_data.exposure_time.to_f
    end

    # 取得したExifデータを用いて新しいPhotoオブジェクトを作成
    photo = Photo.new(
      iso: iso,
      shutter_speed: shutter_speed,
      f_value: f_value,
      camera_model: camera_model,
      latitude: latitude,
      longitude: longitude,
      taken_at: taken_at,
      exposure_time: exposure_time
    )

    # Photoオブジェクトが正常に保存された場合は成功メッセージを、そうでない場合はエラーメッセージをレンダリング
    if photo.save
      render json: { message: 'Image successfully uploaded', id: photo.id, status: :created }
    else
      render json: { errors: photo.errors.full_messages }, status: :unprocessable_entity
    end
  end
end
```

フロントエンドでは、一部パラメータが小数で渡されるので、分数表示のためにtoFraction関数を定義しています。

```jsx
import React, { useState } from "react";

function PhotoList({ photos = [] }) {
  const toFraction = (decimal) => {
    if (decimal == null) {
      return "";
    }
    const gcd = (a, b) => (b ? gcd(b, a % b) : a);
    const len = decimal.toString().length - 2;
    let denominator = Math.pow(10, len);
    let numerator = decimal * denominator;
    const divisor = gcd(numerator, denominator);
    numerator /= divisor;
    denominator /= divisor;
    if (denominator === 1) return `${numerator}`;
    return `${numerator}/${denominator}`;
  };

return(
//省略
<div className="p-5">
    <p className="text-gray-500">
        {new Date(photo.created_at).toLocaleString()}
    </p>
    <p className="text-gray-900">
        カメラ : {photo.camera_model}
    </p>
    <p className="text-gray-900">ISO : {photo.iso}</p>
    <p className="text-gray-900">F値 : {photo.f_value}</p>
    <p className="text-gray-900">
        シャッタースピード :{" "}
    {photo.exposure_time < 1
        ? toFraction(photo.exposure_time)
        : photo.exposure_time}
    </p>

    {photo.taken_at && (
        <p className="text-gray-900">
            撮影日 :{" "}
            {new Date(photo.taken_at).toLocaleString("ja-JP", {
                year: "numeric",
                month: "numeric",
                day: "numeric",
            })}
        </p>
    )}
</div>
);
//省略
}

export default PhotoList;
```

また、ここからはカメラをやっている方向けですが、`exif_data.shutter_speed_value`　で取得した値がいわゆる、1/100秒や10秒といった値だと思っていましたが、`exif_data.exposure_time`　で1/100秒や10秒といったデータを取得できます。

- **exif_data.exposure_time**: 露光時間。1/100や10などシャッターを開けている時間を取得できます。
- **exif_data.shutter_speed_value**: 取得値は1かInfinityのみ。長秒露光の場合はInfinity、それ以外の場合は1を取得できるようです。



## 終わりに



今回、初めて自分だけでサービスを立ち上げ、初の技術記事を書くという経験ができました。

まだエンジニアリングの入り口に立ったに過ぎませんが、今後もサービスづくりを通して、スキルアップを目指していきたいです。

拙い記事かもしれませんでしたが、最後まで読んでいただきありがとうございました！