【Liquid】簡易的な動画配信機能を作ったら、泣けてきた話

## はじめに

どうも納期に追われている駆け出しエンジニアのマナティーです。

私のインターン先の会社では**Shopify**の開発案件を受託しており、ThemeのカスタマイズやShopifyアプリ等を開発しています。

私もThemeのカスタマイズ等々をやっているわけですが、最近、簡易的な動画配信機能を**Shopify Theme**（Liquid）で実装したので、そのTipsを残しておこうと思います。

## この記事で伝えたいこと

- Liquidだけでも軽量な動画配信機能は作れる
- 便利なタグやフィルターの使い方
- Liquidの限界

## 解決したい課題

今回の課題としては以下です。

- あまりコスト（お金、時間）をかけずに軽量な動画配信機能を作りたい
    - 有料アプリを使用しない
    - カスタムアプリを使用しない
        - わざわざ自分でアプリ作りたくない。デプロイコストなどもかかる。
- 軽量な動画配信機能
    - 動画の出し分け機能（今回扱う）
        - 動画を購入していない人→デモ動画
        - 動画を購入した人→本動画
    - 購入フォームの表示切替機能（今回扱わない）
    - 条件
        - 下書き注文は今回の仕様上考えない

上記の課題を踏まえて、上記機能だけなら**Liquid**だけでも実装できそうだったので今回はLiquidで実装しました。

後述しますが、**カスタムアプリ等**で実装するのも選択肢として有りだと思います。

## コードと解説

### 前提条件

今回はセクション、前提条件を以下のように設定しました。

- 動画セクション
    - 動画の出し分け機能
- 前提条件
    - 動画自体はVimeoやYouTube等の外部リンクを使用する
        - Shopifyにアップロードはしない
        - ただ、理論上アップロード形式でも使えなくはないと思います
    - 動画リンクは商品に紐づいているメディア（オブジェクト）を利用する
        - メディアにはデモ動画と本動画以外をアップロードしない
        - 順番は、デモ動画→本動画の順にアップロードする
        - →代替案でメディアオブジェクトを使わない方法に触れます

上記条件は場合によってはきついかなと思うので、代替案の方も参考にしてもらえれば良いかなと思います。

次の項でコードとその解説をしていこうと思います。

### コード

```liquid:video.liquid
 <div>
　{% if customer %}　<!-- １の処理 -->
    {% for media in product.media %} <!-- 2の処理 -->
      {% if forloop.first == true %}　<!--３の処理  -->
        {% if customer.orders == empty %}　<!-- 例外処理 -->
          {{ media | external_video_url | external_video_tag }}
          {% break %}
        {% endif %}
				 <!-- 3-aの処理開始  -->
        {% for order in customer.orders %}
          {% unless order.line_items == empty %}
            {% for item in order.line_items %}
					<!-- 3-a-1の処理開始  -->
              {% if product.id == item.product_id %}
                {% break %}
              {% endif %}
					<!-- 3-a-1の処理終了  -->
				<!-- 3-bの処理開始  -->
              {% if forloop.last == true %}
                {{ media | external_video_url | external_video_tag }}
              {% endif %}
			<!-- 3-bの処終了  -->
            {% endfor %}
			<!-- 3-aの処理終了  -->
          {% else %}
            {{ media | external_video_url | external_video_tag }}
          {% endunless %}
        {% endfor %}
      {% elsif forloop.last == true %}　<!-- 4の処理開始  -->
			<!-- 4-aの処理開始  -->
        {% for order in customer.orders %}
          {% if order.line_items %}
            {% for item in order.line_items %}
              {% if product.id == item.product_id %}
                {{ media | external_video_url | external_video_tag }}
              {% endif %}
            {% endfor %}
          {% endif %}
        {% endfor %}
			<!-- 4-aの処理終了  -->
      {% endif %}
    {% endfor %}
  {% else %}
	<!-- bの処理 -->
    {% for media in product.media %}
      {% if forloop.first == true %}　<!-- デモ動画の表示 -->
        {{ media | external_video_url | external_video_tag }}
      {% endif %}
    {% endfor %}
  {% endif %}
</div>
```

あっ、何か聞こえてきます。

**「「「コード長すぎだろ！！　読めるかバカモン！！　怒」」」**

えー、ごもっともです。皆さんが思っている通りです。
見事に**泣きたくなった**というタイトルの**伏線を回収**したわけです。

冗談はおいて、プログラムの話しに戻りますが私の力不足故にすごい深いネストになってしまいました。一応、動きはするので（クソ）コードの解説を読みながら、**改善点が分かる方がいましたらコメント**で指摘してくださるとマナティーが喜びます。

### コード解説

**※そもそもこのアルゴリズム自体問題ありそうなので、指摘をくれたら嬉しいです。**
おおよその処理順は以下通りです。

1. 顧客がログインしているか
    1. ログインしている場合は２へ
    2. ログインしていない場合はデモ動画をセットする
2. mediaオブジェクトをループで繰り返す
3. １回目のループ
    1. 注文情報（Orderオブジェクト）→支払い済みリスト（line_itemsオブジェクト）→個々の商品ＩＤ（product_id）を取り出す
        1. 商品を購入していたらbreakで抜ける
    2. 最後のループまで行けば、デモ動画を表示
4. ２回目のループ
    1. １回目と同様
    2. 一致したら今度は本物の動画を表示する
5. ループ終了

### ①顧客がログインしているか

<details>
<summary>必要なオブジェクト</summary>
    <ul>
     <li>customerオブジェクト</li>
     <ul>
      <li>顧客がログインしているかを判別するのに使用</li>
     </ul>
     <li>Productオブジェクト</li>
     <ul>
      <li>商品ページの商品情報、特にProduct.idとmediaオブジェクトを使用</li>
     </ul>
     <li>mediaオブジェクト</li>
     <ul>
      <li>デモ動画と本動画を読み込むために使用</li>
     </ul>
     <li>forloopオブジェクト</li>
     <ul>
      <li>インデックス番号などのloop中の情報を取得できるオブジェクト</li>
     </ul>
    </ul>
</details>

これは結構簡単で、**customerオブジェクト**を使用すればログインしているかを判別できます。

```liquid
 {% if customer %}
	ログイン中
{% else %}
	ログアウト中
{% endif %}
```

[公式ドキュメント](https://shopify.dev/api/liquid/objects/customer)によると、

- ログイン中→**customer**オブジェクトが返される
- ログアウト中→**nil**が返される
    - liquid（厳密にはRuby）では、nilはfalseとして扱われる

コードでは、ログインしていない状態のときに以下のコードブロック（bの処理）が実行されます。

```liquid
  {% if customer %}
	省略
 {% else %}
	<!-- bの処理 -->
    {% for media in product.media %}
      {% if forloop.first == true %}　<!-- デモ動画の表示 -->
        {{ media | external_video_url | external_video_tag }}
      {% endif %}
    {% endfor %}
  {% endif %}
```

最初の`{% for media in product.media %} ~~ {% endfor %}`では、productオブジェクト→mediaオブジェクトにアクセスしています。mediaオブジェクトは分かりづらいですが、**配列**（Array）なので個々の要素（＝動画）にアクセスするためにはfor文で回す必要があります。
**media[0].alt**のようにインデックス番号を使ってアクセスすることもできます。

次に飛ばして、`{{ media | external_video_url | external_video_tag }}`について解説します。簡単にいうと、動画のURL（YouTubeやVimeo）を自動的にHTML要素（iframe要素）に変換してくれます。つまり、**動画を実際に表示している箇所**になります。これからこの記述がでてきたら、動画が表示されているのだと認識してもらえれば大丈夫です。

最後に`{% if forloop.first == true %}`では、**初回ループ**時かどうかを判定しています。
mediaオブジェクトの初回ループ時＝**最初の動画**（デモ動画）のときに、**動画を表示する**ということです。

要するに、**ログインしていないユーザーにはデモ動画を表示するため**に上記の処理を書いています。

②mediaオブジェクトをループで繰り返す→①の最後で説明済みのためスキップ

### ③１回目のループ

一番コードが長い部分になります。頑張って理解しましょう。

```liquid
  {% if forloop.first == true %}　<!--３の処理  -->
    {% if customer.orders == empty %}　<!-- 例外処理 -->
      {{ media | external_video_url | external_video_tag }}
      {% break %}
    {% endif %}
		<!--　　 3-aの処理開始  　　　-->
    {% for order in customer.orders %}
      {% unless order.line_items == empty %}
        {% for item in order.line_items %}
		<!--　　　 3-a-1の処理開始  -->
          {% if product.id == item.product_id %}
             {% break %}
          {% endif %}
		<!-- 　　　3-a-1の処理終了  -->
				<!-- 3-bの処理開始  -->
          {% if forloop.last == true %}
             {{ media | external_video_url | external_video_tag }}
          {% endif %}
			<!-- 3-bの処終了  -->
        {% endfor %}
			<!-- 3-aの処理終了  -->
      {% else %}
        {{ media | external_video_url | external_video_tag }}
      {% endunless %}
    {% endfor %}
  {% elsif forloop.last == true %}　<!-- 4の処理開始  -->
```

最初にこのコードがしようとしていることを説明すると、
**動画を購入していないユーザーにはデモ動画を表示している**

これを行うためにすごい長いコードになっています。

最初に初回ループ時（**デモ動画**のとき）という条件で、これが一致したときに３の処理（上記コードブロック）が実行されます。

次に<code>{% if customer.orders == empty %}</code>で例外処理を行っています。
これはcustomerオブジェクトに含まれる、orderオブジェクトの配列にアクセスしています。ログインしている顧客の注文情報を取得して、購入情報がそもそもないユーザーはデモ動画を表示させているわけです。

ここでポイントなのは、**==empty**です。
実はLiquid（Ruby）の場合、空配列はTrueな値として評価されます。そのため以下のようなコードは上手く動きません。

```liquid
 {% if customer.orders == false %}
  {{ media | external_video_url | external_video_tag }}
  {% break %}
{% endif %}
```

この場合、注文をしたことが無くても空配列はTrueとなこのIf文は実行されないため、動画が出力されません。

これを回避するために、falseではなく**empty**というTypeを使用します。emptyはTrueやFalseと同じような式の結果を伝えるデータ型の一つで、**配列やオブジェクトが空の場合**に返される値です。

これを利用することで、注文情報が空の場合という例外時はデモ動画を表示するようにしています。

またもうひとつ重要な点として直後の**`{%break%}`**でループ処理から抜けます。これをしないと下の処理が走って、動画が複数表示されるためです。

emptyやTrueで評価される値については、[公式ドキュメント](https://shopify.dev/api/liquid/basics)を参照してください。

### 注文情報から商品ページと同じ商品のIDの存在を調べる

```liquid
 {% for order in customer.orders %}
      {% unless order.line_items == empty %}
        {% for item in order.line_items %}
		<!--　　　 3-a-1の処理開始  -->
          {% if product.id == item.product_id %}
             {% break %}
          {% endif %}
		<!-- 　　　3-a-1の処理終了  -->
				<!-- 3-bの処理開始  -->
          {% if forloop.last == true %}
             {{ media | external_video_url | external_video_tag }}
          {% endif %}
			<!-- 3-bの処終了  -->
        {% endfor %}
			<!-- 3-aの処理終了  -->
      {% else %}
        {{ media | external_video_url | external_video_tag }}
      {% endunless %}
    {% endfor %}
```

長くなってきたので割愛気味に書きます。

ここで行っていることは、
**個々の注文情報→購入した商品情報→商品のIDを取り出して、閲覧している商品のIDと一致するかを調べる**
ということをしています。

- `{% for order in customer.orders %}`
    - ユーザーの注文情報から個々の注文を取り出す
- `{% for item in order.line_items %}`
    - 購入した商品情報を取り出す

途中で`{% unless order.line_items == empty %}`という見慣れないタグが出てきていますが、これは、if文と同じような使い方をしますが意味が逆で、「もし～**でなかったら**」と否定系になります。
簡単に言語化すると、
**注文情報の中に商品情報が空でなかったら＝注文情報の中に商品情報があれば**
という意味になります。

次に、`{% if product.id == item.product_id %}`で取り出した商品IDと閲覧しているページの商品IDが一致するかを判定し、一致した場合はbreak文でループ処理からでて下の処理をスキップしています。

最後までループが行われた場合＝**IDが一致しなかった**＝**商品を購入していない**と判断して、デモ動画を表示させています。

④も原理的には同じで、今度は一致したときに本動画を表示させています。

## コードの欠点

- コードの可読性が悪い
    - if文、for文のネストや多重ループが発生している
- 正常に動かない可能性がある。
    - Shopifyがループ回数に上限をかけており、多重ループを繰り返すと正常に動作しない可能性がある。
- パフォーマンスが悪い
    - パフォーマンスがいかんせん悪い。

## 代替案

- メディアオブジェクトではなく、metafieldsを使う
    - 動画URLをmetafieldsに保存することで、メディアオブジェクトにスクショなど他のメディアをアップロードすることが可能
- カスタムアプリ（アプリ）の使用
    - セットアップが面倒だが、Admin APIのGraphQLを叩いたらすぐに解決しそう
    - パフォーマンス等を考えるならこっちの方が断然良い

## おわりに

途中から時間がなく走り書きみたいになってしまいましたが、この（クソ）コードが誰かの役に少しでも立てたなら幸いです。

もっと良い方法があるという方はぜひコメントで返信を頂けるとマナティーが喜びます。

長文にお付き合い下さりありがとうございました！

## 参考文献

- 公式ドキュメント
    - [Cusotmerオブジェクト](https://shopify.dev/api/liquid/objects/customer)
    - [nilの扱い](https://shopify.github.io/liquid/basics/types/#nil)
    - [空配列の扱い](https://shopify.dev/api/liquid/basics)
    - [emptyの使い方](https://shopify.dev/api/liquid/basics)