サーバレスでImage Searchに画像登録を行うアプリを作ってみる

# 構成
以前調査した、Image Searchの画像登録をサーバレス構成で作ってみようと思います。フロントエンドは静的コンテンツ（HTML5）で作成し、OSSの静的 Web サイトホスティング機能を使って公開します。サーバサイドはFunction ComputeのHTTPトリガーを使って実装し、Image Searchに画像を登録します。

![01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/40a359d3-2094-c92c-53cc-c6d6f5dc2ec0.png)

※Image Searchについては以前作成したものを使うので、設定については割愛します。

# Function Computeの設定
それでは、まずサーバサイドから作成します。

![01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/852692ac-7250-3399-424f-365d7b3fa7b4.png)
![02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/b9560324-1cb9-c1f6-209e-bba1b65fe84f.png)
![03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/b020f7d3-b9c6-d8cc-dc00-a1951cbb8fff.png)

## HTTP関数のテンプレートを使用します
![04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/5f6644f0-fb2f-d319-2f7b-d1fdd240c6f3.png)
![05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/24039389-9a25-ab49-3fa4-1762bd542654.png)
![06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/03283e0f-6f1f-fa79-0a1e-4921258effcc.png)

## サンプルを実行してみます
![07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/2f46fa6e-7c47-c703-44b7-5fa3bac985f1.png)
![08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/f4115bdd-79cc-5467-2c08-607bf7a0c5e4.png)

## 環境変数の作成
Image Searchへ登録するために環境に依存する設定を環境変数として設定します。

![10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/c9c38bea-c7b3-b840-9478-0dbbe78fde20.png)
![11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/1a7985c0-dd65-fe33-8934-960e47c0270c.png)
![12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/2fa8b8fb-eec9-a4dc-3c90-23ed95fdcebc.png)
![13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/3c82d1b4-b1fc-7d5d-6f9b-5274851b2fcd.png)
![14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/813e24a4-c095-d77f-19d5-317e6de17f66.png)


# Functionsの作成
それでは、Node.jsを使って関数を作成します。

## プロジェクトの作成
```
PS C:\Users\user\Documents\temp> mkdir imgsearch-func


    Directory: C:\Users\user\Documents\temp

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
d----          2020/05/30    16:33                imgsearch-func
```

```
PS C:\Users\user\Documents\temp> cd .\imgsearch-func\
PS C:\Users\user\Documents\temp\imgsearch-func> yarn init
yarn init v1.22.4
question name (imgsearch-func):
question version (1.0.0):
question description:
question entry point (index.js):
question repository url:
question author: 
question license (MIT):
question private:
success Saved package.json
Done in 12.97s.
```

## 依存関係を追加

```
PS C:\Users\user\Documents\temp\imgsearch-func> yarn add @alicloud/pop-core
yarn add v1.22.4
info No lockfile found.
[1/4] Resolving packages...
[2/4] Fetching packages...
[3/4] Linking dependencies...
[4/4] Building fresh packages...

success Saved lockfile.
success Saved 10 new dependencies.
info Direct dependencies
└─ @alicloud/pop-core@1.7.9
info All dependencies
├─ @alicloud/pop-core@1.7.9
├─ @types/node@12.12.42
├─ bignumber.js@4.1.0
├─ debug@3.2.6
├─ httpx@2.2.3
├─ json-bigint@0.2.3
├─ kitx@1.3.0
├─ sax@1.2.4
├─ xml2js@0.4.23
└─ xmlbuilder@11.0.1
Done in 1.54s.
```

```
PS C:\Users\user\Documents\temp\imgsearch-func> yarn add @alicloud/imagesearch-2019-03-25
yarn add v1.22.4
[1/4] Resolving packages...
[2/4] Fetching packages...
[3/4] Linking dependencies...
[4/4] Building fresh packages...
success Saved lockfile.
success Saved 1 new dependency.
info Direct dependencies
└─ @alicloud/imagesearch-2019-03-25@1.0.0
info All dependencies
└─ @alicloud/imagesearch-2019-03-25@1.0.0
Done in 1.62s.
```

```
PS C:\Users\user\Documents\temp\imgsearch-func> yarn add json-to-form-data
yarn add v1.22.4
[1/4] Resolving packages...
[2/4] Fetching packages...
[3/4] Linking dependencies...
[4/4] Building fresh packages...
success Saved lockfile.
success Saved 1 new dependency.
info Direct dependencies
└─ json-to-form-data@1.0.0
info All dependencies
└─ json-to-form-data@1.0.0
Done in 1.17s.
```

## プログラムを作成

``` index.js
'use strict';

const Client = require('@alicloud/imagesearch-2019-03-25')
const jtfd = require('json-to-form-data')
const getRawBody = require('raw-body')
const body = require('body')

//Function
exports.handler = (req, resp, context) => {
  console.log('invoke functions.')

  //ボディからデータ抽出
  getRawBody(req, (err, body) => {
    const param = JSON.parse(body.toString())
    //console.log("Boyd Param:", param)

    const productId = param.ProductId
    const picName = param.PicName
    const categoryId = param.CategoryId
    const intAttr = param.IntAttr
    const strAttr = param.StrAttr
    const customContent = param.CustomContent
    const picContent = param.PicContent

    //値のチェック
    console.log("ProductID:", productId)
    console.log("PicName:", picName)
    console.log("CategoryId", categoryId)
    console.log("IntAttr", intAttr)
    console.log("StrAttr", strAttr)
    console.log("CustomContent,", customContent)
    //console.log("PicContent", picContent)

    //値を設定
    const instanceName = process.env['INSTANCE_NAME']
    const addRequest = {
      InstanceName: instanceName,
      ProductId: productId,
      PicName: picName,
      PicContent: picContent
    }
    if (categoryId) {
      addRequest.CategoryId = categoryId
    }
    if (intAttr) {
      addRequest.IntAttr = +intAttr
    }
    if (strAttr) {
      addRequest.StrAttr = strAttr
    }
    if (customContent) {
      addRequest.CustomContent = customContent
    }
    //console.log("addRequest:", addRequest)

    //Image Searchに登録
    const accessKey = process.env['ACCESS_KEY']
    const seacretKey = process.env['SEACRET_KEY']
    const endpoint = process.env['ENDPOINT']
    console.log("accessKey:",accessKey)
    console.log("seacretKey:",seacretKey)
    console.log("endpoint:",endpoint)

    const client = new Client({
      accessKeyId: accessKey,
      accessKeySecret: seacretKey,
      endpoint: endpoint,
      apiVersion: "2019-03-25"
    })
    const options = {
      method: 'POST',
      "Content-Type": 'application/x-www-form-urlencoded; charset=UTF-8'
    }
    const addData = jtfd(addRequest)
    client.addImage(addData, options).then(value => {
      const responseJson = JSON.stringify(value)
      console.log("Result:", responseJson)
      resp.send(responseJson)
    }).catch(err => {
      console.log("Error Message: ", err)
      resp.send(JSON.stringify(err, null, 4))
    })
  })
  
}
```

## ソースコード解説

関数ハンドラに index.handlerを設定しました。index.js のexport.handlerが呼び出されます。

``` index.js
exports.handler = (req, resp, context) => {
}
```

今回は、BodyにJSONを設定してリクエストを行うようにするので、getRawBody()を使用します。

``` index.js
getRawBody(req, (err, body) => {
}
```


## プロジェクトをアップロード（デプロイ）
今回はZIPアーカイブしてアップロードを行います。

### プロジェクトをZIPで固める

```
PS C:\Users\user> cd .\Documents\temp\imgsearch-func\
PS C:\Users\user\Documents\temp\imgsearch-func> ls
Directory: C:\Users\user\Documents\temp\imgsearch-func
 Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
d----          2020/05/30    16:39                node_modules
-a---          2020/06/08    16:16           2545 index.js
-a---          2020/05/30    16:39            278 package.json
-a---          2020/05/30    16:39           4157 yarn.lock
-a---          2020/05/30    16:49           6575 yarn-error.log

PS C:\Users\user\Documents\temp\imgsearch-func> Compress-Archive -Path * -DestinationPath functions.zip -Force
PS C:\Users\user\Documents\temp\imgsearch-func> ls


    Directory: C:\Users\user\Documents\temp\imgsearch-func

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
d----          2020/05/30    16:39                node_modules
-a---          2020/06/09    17:16         395892 functions.zip
-a---          2020/06/08    16:16           2545 index.js
-a---          2020/05/30    16:39            278 package.json
-a---          2020/05/30    16:39           4157 yarn.lock
-a---          2020/05/30    16:49           6575 yarn-error.log

PS C:\Users\user\Documents\temp\imgsearch-func>
```

### アップロード
![20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/bec5fa6f-ecce-c531-16cb-6db233d59e66.png)
![21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/14386656-248d-5477-3165-8143b97bba56.png)
![22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/91f9bb35-8c88-b83d-f51f-df693f365e63.png)
![23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/ba6d0b43-0df3-5a8e-4678-c81295b8a71e.png)
![24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/33ed8a7f-d153-d7f2-c22e-c43c4a94f2ff.png)

以上で、サーバサイドの構築は完了です。

# OSSの設定
続いて、フロントエンドのデプロイを行います。フロントエンドはOSSの静的 Web サイトホスティング機能を使います。

![30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/c7a775a2-b08c-dcea-4b6b-a68ccfafd7ec.png)
![31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/4d62c06a-7b06-ee41-0ca6-686d6d0601e0.png)
![32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/5c0f8bdb-e43e-0563-bfa7-cf1e5b2c8c34.png)
![33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/abf32b75-a6e7-013d-ff35-c10705445774.png)
![34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/8af7a6dd-c501-0538-536a-7459e085382d.png)
![35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/dac097cb-fc8c-f1fa-b439-4fd821e9dc2b.png)
![36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/e0d0781c-02b2-3f79-4842-1cdd407c08ff.png)
![37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/6ff995b6-8d2b-c63c-78be-9183707225fa.png)
![38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/523edaec-8282-c14e-49ac-4ce6aac6a84f.png)

## フロントエンドの作成


``` index.html
<!DOCTYPE html>
<html>

<head>
    <title>Image Search Add Sample</title>
    <meta charset="utf-8">
    <style type="text/css" scoped>
        ul li {
            display: flex;
            flex-wrap: wrap;
            align-items: center;
            background-color: #EEEEEE;
            margin: 2px;
            padding: 3px;
        }

        ul span:first-of-type {
            flex-basis: 150px;
        }

        ul span:nth-of-type(2) {
            flex-basis: 200px;
            flex-grow: 1;
        }

        ul input[type="text"],
        ul textarea,
        ul select,
        ul input[type="number"] {
            width: 100%;
            box-sizing: border-box;
        }

        #pos {
            border: 3px solid;
            border-color: red;
            position: absolute;
            display: none;
        }
    </style>
</head>

<body>
    <h1>Image Search登録</h1>
    <div>
        <div id="errormsg" style="color:red"></div>
        <ul style="width:800px">
            <li>
                <span><label for="productId">*productId</label></span>
                <span><input type="text" id="productId" maxlength="512" required></span>
            </li>
            <li>
                <span><label for="picName">*picName</label></span>
                <span><input type="text" id="picName" maxlength="512" required></span>
            </li>
            <li>
                <span><label for="categoryId">categoryId</label></span>
                <span><select id="categoryId">
                        <option value="">選択なし</option>
                        <option value="0">トップス</option>
                        <option value="1">ドレス</option>
                        <option value="2">ボトムズ</option>
                        <option value="3">バッグ</option>
                        <option value="4">シューズ</option>
                        <option value="5">アクセサリー</option>
                        <option value="6">スナック</option>
                        <option value="7">メイクアップ</option>
                        <option value="8">ボトルドリンク</option>
                        <option value="9">家具</option>
                        <option value="20">おもちゃ</option>
                        <option value="21">下着</option>
                        <option value="22">デジタル機器</option>
                        <option value="88888888">その他</option>
                    </select></span>
            </li>
            <li>
                <span><label for="intAttr">intAttr</label></span>
                <span><input type="number" id="intAttr" min="0" max="2147483647"></span>
            </li>
            <li>
                <span><label for="strAttr">strAttr</label></span>
                <span><input type="text" id="strAttr" maxlength="128"></span>
            </li>
            <li>
                <span><label for="customContent">customContent</label></span>
                <span><textarea type="text" id="customContent" maxlength="4096" cols="30" rows="5"></textarea></span>
            </li>
            <li>
                <span><label for="picContent">*PicContent : </label></span>
                <span><input type="file" id="picContent" accept="image/jpeg,image/png" required></span>
            </li>
            <li>
                <span>実行</span>
                <span><input type="button" value="登録" id="addImage"><input type="reset" id="clear" value="リセット"></span>
            </li>
            <li>
                <span><label for="postResult">登録結果</label></span>
                <span><textarea id="postResult" cols="100" rows="20" readonly></textarea></span>
            </li>
            <li>
                <span>プレビュー</span>
                <div style="position: relative;">
                    <span><img id="preview"></span>
                    <div id="pos"></div>
                </div>
            </li>
        </ul>
    </div>

    <script>
        //イベントの登録
        document.addEventListener('DOMContentLoaded', () => {
            const productId = document.getElementById('productId')
            const picName = document.getElementById('picName')
            const categoryId = document.getElementById('categoryId')
            const intAttr = document.getElementById('intAttr')
            const strAttr = document.getElementById('strAttr')
            const customContent = document.getElementById('customContent')
            const inputFile = document.getElementById('picContent')
            const preview = document.getElementById('preview')
            const addImage = document.getElementById('addImage')
            const clearbtn = document.getElementById('clear')
            const postResult = document.getElementById('postResult')
            const errormsg = document.getElementById('errormsg')
            const imgpos = document.getElementById('pos')

            // Fileが選択された時のイベントを登録
            inputFile.addEventListener('change', e => {
                console.log(e)
                errormsg.textContent = null
                postResult.value = ""
                imgpos.style.display = "none"

                //プレビューに画像を表示
                const files = e.target.files
                if (files.length) {
                    const file = files[0]
                    const reader = new FileReader()
                    reader.onload = e => {
                        preview.src = e.target.result
                    }
                    reader.readAsDataURL(file)
                }
            }, false)
 
            //登録ボタンが押された時のイベントを登録
            addImage.addEventListener('click', e => {
                console.log(e)
                errormsg.textContent = null
                postResult.value = ""
                imgpos.style.display = "none"

                const width = preview.naturalWidth
                const height = preview.naturalHeight

                //必須チェック
                if (productId.value === '' || picName.value === '' || picContent.value === '') {
                    errormsg.append('必須入力項目が未入力です。')
                    return false
                }
                if (width > 2000 || height > 2000) {
                    errormsg.append('画像サイズは縦横2,000pixel以内でお願いします。')
                    return false
                }

                console.log('Ajax実行！')
                fetch('https://xxxxxx.ap-northeast-1.fc.aliyuncs.com/2016-08-15/proxy/fn-imgsearch-add/fn-imgsearch-add/', {
                    method: 'POST',
                    headers: {
                        "Content-Type": "application/json; charset=utf-8"
                    },
                    body: JSON.stringify({
                        "ProductId": productId.value,
                        "PicName": picName.value,
                        "CategoryId": categoryId.value,
                        "IntAttr": intAttr.value,
                        "StrAttr": strAttr.value,
                        "CustomContent": customContent.value,
                        "PicContent": preview.src.replace(/^data:image\/jpeg;base64,/, '').replace(/^data:image\/png;base64,/, '')
                    })
                })
                    .then((response) => {
                        if (response.status === 200) {
                            return response.json()
                        } else {
                            console.log('Bad')
                        }
                    })
                    .then((obj) => {
                        console.log(JSON.stringify(obj, null, 4))
                        postResult.value = JSON.stringify(obj, null, 4)
                        marker(obj.PicInfo.Region)
                    })
                    .catch((err) => {
                        console.log('Error Occurred')
                        console.log(err)
                        errormsg.append(err)
                    })
            }, false)

            //クリアボタンを押された時のイベントを登録
            clearbtn.addEventListener('click', e => {
                console.log(e)
                productId.value = ""
                picName.value = ""
                categoryId.value = ""
                intAttr.value = ""
                strAttr.value = ""
                customContent.value = ""
                inputFile.value = ""
                postResult.value = ""
                preview.src = ""
                errormsg.textContent = null
                imgpos.style.display = "none"
            }, false)

        }, false)

        // 被写体認識結果をマーキング
        function marker(region) {
            const [x1, x2, y1, y2]  = region.split(',')
            const imgpos = document.getElementById('pos')
            const width = +x2 - +x1
            const height = +y2 - +y1

            imgpos.style.top = y1 + "px"
            imgpos.style.left = x1 + "px"
            imgpos.style.width = width + "px"
            imgpos.style.height = height + "px"
            imgpos.style.display = "inherit"
        }
    </script>
</body>

</html>
```
## 解説

``` xxx.js
fetch('https://xxxxxx.ap-northeast-1.fc.aliyuncs.com/2016-08-15/proxy/fn-imgsearch-add/fn-imgsearch-add/', {
    method: 'POST',
    headers: {
        "Content-Type": "application/json; charset=utf-8"
    },
    body: JSON.stringify({
        "ProductId": productId.value,
        "PicName": picName.value,
        "CategoryId": categoryId.value,
        "IntAttr": intAttr.value,
        "StrAttr": strAttr.value,
        "CustomContent": customContent.value,
        "PicContent": preview.src.replace(/^data:image\/jpeg;base64,/, '').replace(/^data:image\/png;base64,/, '')
    })
})
    .then((response) => {
        if (response.status === 200) {
            return response.json()
        } else {
            console.log('Bad')
        }
    })
    .then((obj) => {
        console.log(JSON.stringify(obj, null, 4))
        postResult.value = JSON.stringify(obj, null, 4)
        marker(obj.PicInfo.Region)
    })
    .catch((err) => {
        console.log('Error Occurred')
        console.log(err)
        errormsg.append(err)
    })
```

fetchの部分が、Function Computeに対してリクエストをしている部分です。入力されたパラメータをJSON形式にして、bodyに設定します。他にもFormDataにする方法などいろいろとやり方はありますが、今回はこのようにしました。

戻り値には登録した画像のカテゴリーと、物体検出の結果の座標が返されるのでプレビューに表示してみました。商品以外の製品が乗っている画像を登録する場合、どの部分が物体検出されるかによって精度が変わるので、チューニングの際に見ておきたい部分ですね。

## ファイルのアップロード
![39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/646ee56f-7cd9-ea4b-c49e-8204b493b942.png)

# 実行

index.htmlのパスを確認してアクセスしてみます。
![40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/abe720a7-efc4-3c4f-a83a-49b0a6aa65a7.png)
![41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/178e0969-ba0d-d725-d2c1-2200f8f7bf3f.png)
![42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/0e2dd9eb-efd0-7e36-1f4e-96b5af38cc70.png)
![43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18582/6189fd25-4868-0e9a-0b09-fa0becbb8bbd.png)

無事に実行する事ができました！

# まとめ
今回は、サーバレス構成でアプリを作ってみました。例外処理やバリデーションなどは割愛しています。。。次回はAPI Gatewayを使ってみようかと思います。