HTTPSに対応したAlibaba CloudのAPI GatewayがAndroidでNetwork request failedする問題

API GatewayHTTPS化したらreact-nativeのAndroidアプリからfetchできなくなってしまいました。

HTTPへのリクエストは成功するのですがHTTPSへのリクエストを送ると以下のエラーが出てしまいます。

TypeError: Network request failed
    at XMLHttpRequest.xhr.onerror (blob:http://localhost:8081/2cf03b53-c0b2-404c-8dda-906f61a7d7ee:27276:18)
    at XMLHttpRequest.dispatchEvent (blob:http://localhost:8081/2cf03b53-c0b2-404c-8dda-906f61a7d7ee:29368:27)
    at XMLHttpRequest.setReadyState (blob:http://localhost:8081/2cf03b53-c0b2-404c-8dda-906f61a7d7ee:29121:20)
    at XMLHttpRequest.__didCompleteResponse (blob:http://localhost:8081/2cf03b53-c0b2-404c-8dda-906f61a7d7ee:28948:16)
    at blob:http://localhost:8081/2cf03b53-c0b2-404c-8dda-906f61a7d7ee:29058:47
    at RCTDeviceEventEmitter.emit (blob:http://localhost:8081/2cf03b53-c0b2-404c-8dda-906f61a7d7ee:3707:37)
    at MessageQueue.__callFunction (blob:http://localhost:8081/2cf03b53-c0b2-404c-8dda-906f61a7d7ee:2593:44)
    at blob:http://localhost:8081/2cf03b53-c0b2-404c-8dda-906f61a7d7ee:2370:17
    at MessageQueue.__guard (blob:http://localhost:8081/2cf03b53-c0b2-404c-8dda-906f61a7d7ee:2546:13)
    at MessageQueue.callFunctionReturnFlushedQueue (blob:http://localhost:8081/2cf03b53-c0b2-404c-8dda-906f61a7d7ee:2369:14)

ドキュメントやissueやらを読み漁って悩んでいると、以下のコメントを見つけました。どうやら証明書が間違っている時にもこのエラーが出るそうです。

github.com

解決法

中間CA証明書とクロスルート証明書を追加したら解決しました。

さくらのRapidSSLだとこの2つの証明書はここにあって、それぞれコピペします。

そしてAPI GatewaySSL証明書を追加するテキストエリアに以下のように証明書を3つまとめて入力します。

-----BEGIN CERTIFICATE-----
SSL証明書
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
中間CA証明証
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
クロスルート証明書
-----END CERTIFICATE-----

これで私は通信できるようになりました。

参考

qiita.com

Alibaba CloudのAPI GatewayをHTTPS化する

独自ドメインを設定したAPI GatewayのエンドポイントをHTTPS対応させます。なお、Function Computeをバックグラウンドで動かしています。

基本は以下の公式ドキュメントを参考にしました。

jp.alibabacloud.com

SSL証明書の取得

私はさくらのRapidSSLで取得しました。SSL証明書買ったの久々です。

CSR作成なんかは完全に忘れていましたので以下を参考にしました。

qiita.com

サーバー認証

API Gatewayでちょっと苦労するのがサーバー認証キーのアップロードです。

普通のサーバーのようにファイルをアップロードすることはできないので、API Gatewayのエンドポイントを/.well-known/pki-validation/fileauth.txtと設定してGETで認証キーを返すようにします。

webコンソール画面からだとAPI Gatewayのエンドポイントに/.well-known/pki-validation/fileauth.txtを設定することができなかった(多分ドットがバリデーションエラーになる)ので、template.ymlを使ってデプロイします。

ROSTemplateFormatVersion: '2015-09-01'
Transform: 'Aliyun::Serverless-2018-04-03'
Resources:
  MyService: # service name
    Type: 'Aliyun::Serverless::Service'
    Properties:
      Description: 'my fc'
    myfunction: # function name
      Type: 'Aliyun::Serverless::Function'
      Properties:
        Handler: index.handler
        Runtime: nodejs8
        CodeUri: './'
        Timeout: 60

  MyFunctionGroup: # group name
    Type: 'Aliyun::Serverless::Api'
    Properties:
      StageName: RELEASE
      DefinitionBody:
        '/.well-known/pki-validation/fileauth.txt':
          get:
            x-aliyun-apigateway-api-name: myfunction
            x-aliyun-apigateway-fc:
              arn: acs:fc:::services/${MyService.Arn}/functions/${myfunction.Arn}/

index.jsはこちらです。

module.exports.handler = (event, context, callback) => {
  callback(null, { statusCode: 200, body: 'YOUR KEY IN fileauth.txt' });
};

API GatewayのFunction GroupにSSL証明書を追加する

画面をポチポチしてSSL証明書を追加します。

Function GroupからSSL証明書を追加します。 f:id:asmsuechan:20180912160130p:plain

秘密鍵はパスワードを削除する必要があります。 f:id:asmsuechan:20180912161000p:plain

HTTPSで通信できるようにする

次にAPI GatewayHTTPSを受け取るように変更します。template.ymlで設定を定義している人は画面からでは変更できません。template.ymlを変更しましょう。

template.ymlに以下を追加します。

            x-aliyun-apigateway-request-config:
              requestProtocol: 'https'

以下はtemplate.ymlの全体です。

ROSTemplateFormatVersion: '2015-09-01'
Transform: 'Aliyun::Serverless-2018-04-03'
Resources:
  MyService: # service name
    Type: 'Aliyun::Serverless::Service'
    Properties:
      Description: 'my fc'
    myfunction: # function name
      Type: 'Aliyun::Serverless::Function'
      Properties:
        Handler: index.handler
        Runtime: nodejs8
        CodeUri: './'
        Timeout: 60

  MyFunctionGroup: # group name
    Type: 'Aliyun::Serverless::Api'
    Properties:
      StageName: RELEASE
      DefinitionBody:
        '/':
          get:
            x-aliyun-apigateway-api-name: myfunction
            x-aliyun-apigateway-request-config:
              requestProtocol: 'https'
            x-aliyun-apigateway-fc:
              arn: acs:fc:::services/${MyService.Arn}/functions/${myfunction.Arn}/

これをfun deployしたらHTTPS対応が完了します。

Tips

curlを送った時のレスポンスのserverがTengineならFunction Computeまでリクエストが渡っていて、nginxならAPI Gatewayがレスポンスを返しています。

Alibaba CloudのAPI Gatewayに独自ドメインを紐付ける

API Gatewayはデフォルトの状態で使うと1日に1000回のリクエスト制限があります。これを回避するにはFunction Groupに独自ドメインを設定しなければなりません。

ここではAPI Gateway独自ドメインを設定する方法について書きます。

asmsuechan.hatenablog.com

Alibaba Cloud DNSの設定

まず、どこかでドメインを取得します。私はお名前.comを使いました。

取得が完了したらドメイン名を追加よりドメインを追加します。 f:id:asmsuechan:20180908211541p:plain

無効なDNSサーバーと表示されるので、お名前.comにから対象のドメイン名のDNSサーバーをns7.alidns.com, ns8.alidns.comに変更します。お名前.comではネームサーバーの設定より変更することができます。

追加したらDNSサーバー:ns7.alidns.com, ns8.alidns.comと表示されます。

f:id:asmsuechan:20180908211722p:plain

次にレコードを追加をします。

f:id:asmsuechan:20180909162243p:plain

ホストにサブドメイン名、値にデフォルトで設定されるAPI Gatewayのエンドポイントを入力します。

Alibaba Cloud API Gatewayの設定

ドメイン名のバインドよりドメイン名をAPI GatewayのFunction Groupに紐付けます。

f:id:asmsuechan:20180909174248p:plain

これで完了です。

Alibaba Cloud API Gatewayのアクセス制限に気をつけよう

最終的にはドキュメントちゃんと読めって話ですが、Alibaba CloudのAPI Gatewayを使っているときに403で悩まされました。

以下のリクエストを送った時、API Gateway + Function ComputeのAPIから突然403が返ってくるようになりました。

$ curl -i -H "Content-Type: application/json" -H 'Authorization: Bearer MY_JWT' -X POST -d '
{ "query": "query getPosts { posts(limit: 100) { id url body user_id created_at updated_at } }"}
' http://xxxxxxxxxxxxxxxxxxxxxxxxxx-ap-northeast-1.alicloudapi.com/
HTTP/1.1 403 Forbidden
Server: Tengine
Date: Sat, 08 Sep 2018 11:36:14 GMT
Content-Type: text/plain;charset=UTF-8
Content-Length: 0
Connection: keep-alive
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,HEAD,OPTIONS,PATCH
Access-Control-Allow-Headers: X-Requested-With,X-Sequence,X-Ca-Key,X-Ca-Secret,X-Ca-Version,X-Ca-Timestamp,X-Ca-Nonce,X-Ca-API-Key,X-Ca-Stage,X-Ca-Client-DeviceId,X-Ca-Client-AppId,X-Ca-Signature,X-Ca-Signature-Headers,X-Ca-Signature-Method,X-Forwarded-For,X-Ca-Date,X-Ca-Request-Mode,Authorization,Content-Type,Accept,Accept-Ranges,Cache-Control,Range,Content-MD5
Access-Control-Max-Age: 172800
X-Ca-Request-Id: EE2517BA-5775-46AB-8339-B96AF926367F
X-Ca-Error-Message: Throttled by DOMAIN Flow Control

こう言う時はX-Ca-Error-Messageを見てみましょう。Throttled by DOMAIN Flow Controlと書いてあります。

以下のリンクによると、

The second-level domain name used for API calls can be accessed up to 1,000 times each day.Each group has limited 500 QPSs.

だそうで、API GatewayはデフォルトのURLだと1日に1000回しかアクセスできないみたいです。

www.alibabacloud.com

これを解決するには独自ドメインを紐づけるのが手っ取り早いです。

独自ドメインの使用方法は近日書きます。

react-nativeで画像が表示されない

react-nativeの<Image>タグが画像を表示してくれなかったときの私の解決法です。

問題

<Image source={{ uri: item.imageUrl }} style={{ height: item.height }}/>のようにすると画像が表示されない時がある。

このアプリ画面中には画像が敷き詰められるはずなのですがいくつかうまく表示されていません。

f:id:asmsuechan:20180906090936p:plain

解決法

画像サイズを小さくする。

どうやら3MB以上の画像はうまく表示されないようです。リサイズしたら容量も小さくなってうまく表示されるようになりました。

f:id:asmsuechan:20180906091605p:plain

Alibaba Cloud OSSでリサイズされた画像を取得する方法

例えば3000x1500の画像を400x200にして返すようにします。

イメージ処理から新しいスタイルを作成します。

f:id:asmsuechan:20180905171841p:plain

固定幅とオートフィット高さを選択したら可変高さとなります。

f:id:asmsuechan:20180905172044p:plain

これだけです。あとは画像のURLにパラメーターとしてドメイン名/sample.jpg?x-oss-process=style/asshukuを追加すれば取得できます。

react-nativeからalibaba cloud OSSにファイルをアップロードする

react-nativeからOSSにファイルをアップロードする方法を調べました。

イメージしやすいようにアップロード画面のスクショを貼っておきます。Sendボタンを押したらアップロードされます。

f:id:asmsuechan:20180905111538p:plain

なお、画像の選択にはreact-native-image-pickerを使っています。

選択肢

react-nativeからOSSにファイルをアップロードする手法の選択肢は4つあります。

  • ali-ossを使う
  • aliyun-oss-react-nativeを使う
  • function compute + API Gatewayを使う
  • function compute + HTTPトリガーを使う

最終的にfunction compute + HTTPトリガー画像アップロードサービスを作成して実現しました。

ali-ossを使う

OSSの公式のnode.js用SDKとしてali-ossがありますが、依存関係の問題でreact-native上では直接使えないっぽいので無理でした。

aliyun-oss-react-nativeを使う

公式のライブラリとしてaliyun-oss-react-nativeがあるのでこれを使います。

// this.state.imageにはreact-image-pickerで選択した画像が入っています。
import AliyunOSS from 'aliyun-oss-react-native'

onSendPress () {
  const credentials = {
    accessKeyId: 'xxxxxxxxxxx',
    accessKeySecret: 'xxxxxxxxxxxxxxxxxxxxxxxxx',
    endPoint: 'oss-ap-northeast-1.aliyuncs.com'
  };
  const configuration = {
     maxRetryCount: 3,
     timeoutIntervalForRequest: 30,
     timeoutIntervalForResource: 24 * 60 * 60
  };
  AliyunOSS.initWithPlainTextAccessKey(credentials.accessKeyId, credentials.accessKeySecret, credentials.endPoint, configuration)

  AliyunOSS.asyncUpload('my bucket name', this.state.image.fileName, `file://${this.state.image.path}`).then((res) => {
    console.log(res)
  }).catch((error)=>{
    console.log(error)
  })
}

これでアップロードは可能なのですが、2.5MB程度の画像を送ろうとするとアップロードに20秒、レスポンスが返ってくるまで40秒くらいかかりました。めちゃくちゃ遅い。断念。ってかなんでだろう。。。

function compute + API Gatewayを使う

react-nativeからOSSに直接ファイルを送るのはやめて、一旦こちらで用意したアップロードサービスに画像を送ってそいつにアップロードを任せるようにします。

以下のコードをfunction computeにデプロイします。

const atob = require('atob');
const OSS = require('ali-oss');

module.exports.handler = (event, context, callback) => {
  const client = new OSS({
    region: 'oss-ap-northeast-1',
    accessKeyId: 'xxxxxxxxxxxx',
    accessKeySecret: 'xxxxxxxxxxxxxxxxxxxx',
  });

  const request = JSON.parse(event.toString('utf8'));
  const parameters = JSON.parse(atob(request["body"]));

  client.useBucket('my bucket name');
  client.put(parameters['fileName'], new Buffer(parameters['data'], 'base64'), {mime: parameters['type']}).then((response) => {
    if (response.res.statusCode === 200) {
      callback(null, { statusCode: 200, body: {url: response.url}});
    } else {
      callback(null, { statusCode: 400, body: {msg: 'bad request'}});
    }
  });
};

これならいけると思ったのですが、API Gatewayにはリクエストの最大長が2MBという制限(公式ドキュメント)があるので2MB以上のデータを送信しようとすると無情にも503が返ってきてしまうので断念。

function compute + HTTPトリガーを使う

次にHTTPトリガーを試します。こっちは6MBまでのデータを送信できます

以下のコードをfunction computeにデプロイします。API GatewayとHTTP Triggerでインターフェイスが違うの、結構めんどくさいです。

const OSS = require('ali-oss')
const getRawBody = require('raw-body')

module.exports.handler = function(request, response, context) {
  getRawBody(request, (err, data) => {
    const client = new OSS({
      region: 'oss-ap-northeast-1',
      accessKeyId: 'xxxxxxxxxxxx',
      accessKeySecret: 'xxxxxxxxxxxxxxxxx',
    });

    client.useBucket('my_bucket_name');
    const requestBody = JSON.parse(data.toString('utf8'));
    console.log(requestBody)
    client.put(requestBody.fileName, new Buffer(requestBody.data, 'base64'), {mime: requestBody.type}).then((ossRes) => {
      if (ossRes.res.statusCode === 200) {
        const respBody = new Buffer(JSON.stringify({url: ossRes.url}))
        response.setStatusCode(200)
        response.setHeader('content-type', 'application/json')
        response.send(respBody)
      } else {
        const respBody = new Buffer(JSON.stringify({msg: 'bad request'}))
        response.setStatusCode(400)
        response.setHeader('content-type', 'application/json')
        response.send(respBody)
      }
    });
  })
}

bodyにいろいろ詰め込んで投げると画像が保存できます。レスポンスも早いです。

$ curl -i -H "Content-Type: application/json" -X POST -d '
{"fileName": "greate2.jpg", "data": "iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAACNklEQVQ4EeWTz0tUURTHP+/xnN7kZAjZkIYllpX0uxYtLBAiqIWioVAglIt+yeAigvAfaNOuRW6UIkiCIFwEQWmrCMqiCEm0smZQMcmZ9+bnc957J96bZiIIW+TOC/fecy/n+73nfM+5SjqdFlZwqCvI5VOtQkLNVvQVlXEVavj/KVtLEE+AFP5HgXBiksyRoyiOg2LbyOMnkLP+WSz34SPMhq2Y+xtJbqtHPoxTitBNp3wCcYXklW7I5ZYnjMZIXe0hNHif9Z+iBC/1kj5z9jehh5aZWVIHD/tE5oE9yJt3sLiI1X0Zc0s1Zl0tzq1+cF3IZNGbTqI2H0PKNLRTJ5B8DhIjL8V4+1Fm6veKEZ2X+OADiYVrJN5/T4zpOZk/3i7zza1ijI1LfGhYYlVhSbyfFCPjlKZpWDK3r0kWzkVEtZ+NllJTKitR21tRgxWo7S0Q3kigtwd94DY07kRt2I6i6fB9oYTxipG7GME1fhC4eQPN/fK1kELRxbYLlmVBKIRStYFMWyf27FTRA9SS9BCNYY0MU/HiNawNogb6rv3pUIRpZb5OqY7T6JEeX/iKqW+o66qKHqU92NYF1Zv8s+o8HYViVN6V6yBOHiYmIZsFO4+yuQZxHNyBu7jJBfCi/zXETKKEysHrR0DVznchuo5aHvIvlDU6gd2HMDtb4PM0wQsRkl0dmDvqyI88R6vdRfZ6n9+zHsB9NUZ26A5k0gW8Vy3fWm7xXvdaJaj7RLKU9+2/QX4CzuX6MmaoX4UAAAAASUVORK5CYII=", "type": "image/jpg"}
' https://xxxxxxxx.ap-northeast-1.fc.aliyuncs.com/2016-08-15/proxy/image_service/upload/
HTTP/1.1 200 OK
Access-Control-Expose-Headers: Date,x-fc-request-id,x-fc-error-type,x-fc-code-checksum,x-fc-invocation-duration,x-fc-max-memory-usage,x-fc-log-result,x-fc-invocation-code-version
Content-Length: 71
Content-Type: application/json
X-Fc-Code-Checksum: 17416432931740708308
X-Fc-Invocation-Duration: 55
X-Fc-Max-Memory-Usage: 25.38
Date: Wed, 05 Sep 2018 02:02:53 GMT

{"url":"http://xxxxxxxxx.oss-ap-northeast-1.aliyuncs.com/greate2.jpg"}

さて、react-nativeから画像を投げるようにします。

// this.state.imageにはreact-image-pickerで選択した画像が入っています。
onSendPress () {
  const body = JSON.stringify({ type: this.state.image.type, fileName: this.state.image.fileName, data: this.state.image.data })
  console.log(body)
  fetch('https://xxxxxxxxx.ap-northeast-1.fc.aliyuncs.com/2016-08-15/proxy/image_service/upload/', {
    method: 'POST',
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/json',
    },
    body: body
  }).then(response => {
    const jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ik5FSkNORVpGTkRJeFJEVTBSa1F6TkRORE56aERNakZFTTBKQlJUWkZOelJHT1RNNE1EZzJSUSJ9.eyJpc3MiOiJodHRwczovL2t1bW9ub3RhbWkuYXV0aDAuY29tLyIsInN1YiI6ImF1dGgwfDViOGIyNGZlYjk3NTFiMjAyOTQyMDkxMSIsImF1ZCI6IjBBcll6OVZqQkR1Mk43M1JSUVhNWVR6S1duNDd6N1E1IiwiaWF0IjoxNTM2MDI3NDMxLCJleHAiOjE1MzYwNjM0MzEsImF0X2hhc2giOiI4UVB4X3NsVWpNc09wZHdDUHV5ZlZnIiwibm9uY2UiOiJQT2M5MjM3NXRWNGFtOEpadlhtNHZGOG93NTRLeG05WSJ9.KKgNaeG7YTp7CP3hHA1v4yRT8NacWTqliGhziTu3IFtX6l7FoXiT5SVri0zYfYrNMRyGVBt5y9t6th8ttTKjVGW6_6Ad8r3OaMezDU_OnKXlqu5JOAKXIgLeVtvkfXkc7gcs2_hB--ZAqQfeM7lbm-86zZUdnTuhZSGzdKaPmuzowUEXSqpB2-HEQqKTVm6jKQqoDxamwLrrHuuUFq7U4-9l_Qx40ScUtwLzlk85aoMoI7au3oUvWdyJdY4x_BAShXsgzqoVXXUREG-nAYIJ6TzbJLOSe-3A3Rf9q4YfUX_WK-cGD4ROBIOfX6GUR5AHYEOL6Xh9p0dvQelb2-pN4w"
  }).catch(err => { console.log(err) })
}

できました。3MBくらいの画像もすんなりアップロードできます。6MB以上のファイルのアップロードはできないようにしましょう。

まとめ

react-nativeからOSSに画像をアップロードするならimage upload serviceを別に立てましょう。