2013-12-23

apexからAmazon Elastic Transcoderを触ってみる。

Amazon Elastic TranscoderとはAmazon Web Serviceの一つ(ベータ版)で

メディア変換をできるクラウドサービス。

 

あんまりメディアファイルを扱ったこと無いから利用イメージが湧かないが、

iOS用のストリーミング技術のHLSへの変換が簡単そうなので触ってみることにした。

 

基本的な使い方は安定のクラスメソッドさんのブログで↓

http://dev.classmethod.jp/cloud/amazon-elastic-transcoder-start/

http://dev.classmethod.jp/references/amazon-elastic-transcoder-hls/

 

一般的なWeb言語のSDKは存在するものの、apex用のSDKはもちろん無いので

pipeline(入出力バケットの設定)とjob(実際の変換操作)の作成部分を作ってみた。

 

/**
 * AETコモンクラス
 */
public with sharing class AETCommon {
	private final String SERVICE = 'elastictranscoder';

    /**
     * Access Key Id
     */
    private String AWSAccessKeyId = EnvSetting__c.getOrgDefaults().AWSAccessKeyID__c;

    /**
     * Access Key Secret
     */
    private String AWSAccessKeySecret = EnvSetting__c.getOrgDefaults().AWSAccessKeySecret__c;

    /**
     * パイプラインを作成
     */
    public String createPipeLine(
        String region, 
        String pipelineName,
        String inputBucket,
        String outputBucket,
        String thumnailBucket,
        String role
    ) {
        Map<String, String> params = new Map<String, String>{

        };

        List<String> signedHeaders = new List<String>{
            'host', 'x-amz-date'
        };

        DateTime dt = DateTime.now();
        String credentialScope = dt.formatGmt('YYYYMMdd') + '/' + 
                                 region + '/' + SERVICE + '/aws4_request';

        //Body
        Map<String, Object> jsonMap = new Map<String, Object>{
            'Name' => pipelineName,
            'InputBucket' => inputBucket,
            'OutputBucket' => outputBucket,
            'Role' => role,
            'Notifications' => new Map<String, Object>{
            	'Progressing' => '',
            	'Completed' => '',
            	'Warning' => '',
            	'Error' => ''
            }
        };
        String jsonBody = Json.serialize(jsonMap);

        String resource = '/2012-09-25/pipelines';

        String stringToSign = this.createStringToSign(
            'AWS4-HMAC-SHA256',
            dt, 
            credentialScope, 
            this.createHashedCanonicalRequest(
                'SHA256',
                'POST',
                resource,
                params,
                new Map<String, String>{
                    'Host' => 'elastictranscoder.' + region + '.amazonaws.com',
                    'x-amz-date' => dt.formatGmt('YYYYMMdd') + 'T' + dt.formatGmt('HHmmss') + 'Z'
                },
                signedHeaders,
                jsonBody
            )
        );

        String signature = this.createSignature(
            'hmacSHA256',
            dt,
            region,
            SERVICE,
            Blob.valueOf(stringToSign)
        );

        return this.callCommonAETCall(
            dt,
            'elastictranscoder.' + region + '.amazonaws.com',
            credentialScope,
            signedHeaders,
            signature,
            resource,
            jsonBody
        );
    }

    /**
     * jobを作成
     */
    public String createJob(
        String region, 
        String pipelineId,
        String inputKey,
        String outputKey,
        String outputKeyPrefix
    ) {
    	Map<String, String> params = new Map<String, String>{

    	};

        List<String> signedHeaders = new List<String>{
            'host', 'x-amz-date'
        };

    	DateTime dt = DateTime.now();
    	String credentialScope = dt.formatGmt('YYYYMMdd') + '/' + 
					             region + '/' + SERVICE + '/aws4_request';

        //Body
        Map<String, Object> jsonMap = new Map<String, Object>{
        	'PipelineId' => pipelineId,
            'Input' => new Map<String, Object>{
                'Key' => inputKey
	        },
	        'Outputs' => new List<Map<String,Object>>{
	        	new Map<String, Object>{
	        		'Key' => outputKey,
	        		'PresetId' => '1351620000001-200050',
	        		'SegmentDuration' => '5'
	        	}
	        },
	        'OutputKeyPrefix' => outputKeyPrefix
    	};
    	String jsonBody = Json.serialize(jsonMap);

        String stringToSign = this.createStringToSign(
            'AWS4-HMAC-SHA256',
            dt, 
            credentialScope, 
            this.createHashedCanonicalRequest(
                'SHA256',
                'POST',
                '/2012-09-25/jobs',
                params,
                new Map<String, String>{
                    'Host' => 'elastictranscoder.' + region + '.amazonaws.com',
                    'x-amz-date' => dt.formatGmt('YYYYMMdd') + 'T' + dt.formatGmt('HHmmss') + 'Z'
                },
                signedHeaders,
                jsonBody
            )
        );

        String signature = this.createSignature(
            'hmacSHA256',
            dt,
            region,
            SERVICE,
            Blob.valueOf(stringToSign)
        );

        return this.callCommonAETCall(
	        dt,
	        'elastictranscoder.' + region + '.amazonaws.com',
	        credentialScope,
	        signedHeaders,
	        signature,
	        '/2012-09-25/jobs',
	        jsonBody
	    );
    }

    /**
     * AETへのAPIコールの共通メソッド
     */
    private String callCommonAETCall(
        DateTime dt,
        String hostname,
        String credentialScope,
        List<String> signedHeaders,
        String signature,
        String resource,
        String jsonBody
    ) {
    	HttpRequest req = new HttpRequest();
        req.setHeader('Host', hostname);
        req.setHeader(
            'x-amz-date', 
            dt.formatGmt('YYYYMMdd') + 'T' + dt.formatGmt('HHmmss') + 'Z'
        );

        req.setHeader('Content-Type', 'application/json; charset=UTF-8');
        req.setHeader('Content-Length', String.valueOf(jsonBody.length()));

        req.setHeader(
            'Authorization',
            'AWS4-HMAC-SHA256 Credential=' + this.AWSAccessKeyId + '/' + credentialScope + ',' +
            'SignedHeaders=' + String.join(Utility.getLowerCaseSortedList(signedHeaders), ';') + ',' + 
            'Signature=' + signature
        );
        req.setEndpoint('https://' + hostname + resource);
        req.setMethod('POST');

        req.setBody(jsonBody);

        Http http = new Http();
        HTTPResponse res = http.send(req);
        return res.getBody();
    }

    /**
     * HashedCanonicalRequestを作成
     */
    private String createHashedCanonicalRequest(
        String algorithm,
        String method,
        String url,
        Map<String, String> params,
        Map<String, String> headers,
        List<String> signedHeaders,
        String payload
    ) {    	
        String signature = '';
        signature += method + Constants.LF;
        signature += url + Constants.LF;
        signature += Utility.getSortedParam(params) + Constants.LF;
        signature += Utility.createCanonicalHeaders(headers) + Constants.LF;
        signature += String.join(Utility.getLowerCaseSortedList(signedHeaders), ';').toLowerCase() + Constants.LF;
        signature += EncodingUtil.convertToHex(
                        Crypto.generateDigest(
                            algorithm,
                            Blob.valueOf(payload)
                        )
                     );
        return EncodingUtil.convertToHex(
	        Crypto.generateDigest(
	            algorithm, 
	            Blob.valueOf(signature)
	        )
	    ).toLowerCase();
    }

    /**
     * StringToSignを作成
     */
    private String createStringToSign(
        String algorithm,
        DateTime requestDateTime,
        String credentialScope,
        String hashedCanonicalRequest
    ) {
    	String stringToSign = algorithm + Constants.LF;
    	stringToSign += 
    	   requestDateTime.formatGmt('YYYYMMdd') + 'T' + 
    	   requestDateTime.formatGmt('HHmmss') + 'Z' + Constants.LF;
        stringToSign += credentialScope + Constants.LF;
        stringToSign += hashedCanonicalRequest;
        return stringToSign;    	
    }

    private String createSignature(
        String algorithm,
        DateTime requestDateTime,
        String region,
        String service,
        Blob stringToSign
    ) {
    	system.debug(stringToSign.toString());
    	Blob kDate = Crypto.generateMac(
			algorithm,
			Blob.valueOf(requestDateTime.formatGmt('YYYYMMdd')),
                        Blob.valueOf('AWS4' + this.AWSAccessKeySecret)
                     );
        Blob kRegion = Crypto.generateMac(
            algorithm,
            Blob.valueOf(region),
            kDate
        );
        Blob kService = Crypto.generateMac(
            algorithm,
            Blob.valueOf(service),
            kRegion
        );

        Blob kSigning = Crypto.generateMac(
            algorithm,
            Blob.valueOf('aws4_request'),
            kService
        );

        return EncodingUtil.convertToHex(
            Crypto.generateMac(
		algorithm,
		stringToSign,
                kSigning
            )
        );
    }
}

/**
 * Utilityクラス
 */
public without sharing class Utility {
    /**
     * key=value&...でつないだ文字列を返却
     * @param mapParam 対象文字列=>文字列マップ
     * @return key=value&...でつないだ文字列
     */
    public static String getSortedParam(Map<String,String> mapParam){
        if (mapParam == null || mapParam.keySet().isEmpty()) {
            return '';
        }
        String param = '';
        List sortedKey = getSortedList(mapParam.keySet());
        for(String key: sortedKey){
            param += percentEncodeRfc3986(key) + '=' + percentEncodeRfc3986(mapParam.get(key)) + '&';
        }
        return param.substring(0,param.length() - 1);
    }

    public static String percentEncodeRfc3986(String s) {
        return EncodingUtil.urlEncode(s, 'UTF-8')
                    .replace('+', '%20')
                      .replace('*', '%2A')    
                      .replace('%7E', '~');
    }

    public static String createCanonicalHeaders(Map<String, String> headers) {
    	if (headers == null || headers.keySet().isEmpty()) {
            return '';
        }

        Map<String, String> lowerCaseHeaders = new Map<String, String>();
    	for (String key : headers.keySet()) {
    		lowerCaseHeaders.put(key.toLowerCase(), headers.get(key));
    	}
    	String param = '';
        List sortedKey = getSortedList(lowerCaseHeaders.keySet());
        for(String key: sortedKey){
            param += key + ':' + lowerCaseHeaders.get(key).trim() + '\n';
        }
        return param;
    }

    public static List getLowerCaseSortedList(List keys) {
    	List sortedKeys = new List();
        for (String key : keys) {
        	sortedKeys.add(key.toLowerCase());
        }
        sortedKeys.sort();
        return sortedKeys;
    }
}

 

Signature Version 4っていう署名生成規則でリクエストしないといけないんだけど、見事にハマった…。

  1. HashedCanonicalRequestを作成(上記サンプルのcreateHashedCanonicalRequest)

  2. 1を使ってStringToSignを作成(上記サンプルのcreateStringToSign)

  3. StringToSign、SecretKey、その他パラメータからSignature生成(上記サンプルのcreateSignature)

っていう流れで、Signatureが間違っている場合(The request signature we calculated does not match the signature you provided.って言われる )は

正解のHashedCanonicalRequestとStringToSignをエラーメッセージと同時に教えてくれるので、

これを参考にプログラム側を修正していけば、いずれ成功します。

 

ちなみにapexでのSignature Version4に関しては

テラスカイさんのブログ(http://www.terrasky.co.jp/blog/?p=3280)でも紹介されてるので、

そちらも参考すると良い感じ。

 

上記サンプルはmp4とかの形式の動画ファイルをHLS形式(tsファイル&m3u8ファイル)にするもので

「salesforceから動画をアップロードしてiOSでストリーミング配信したい!」

とか言う要件があった場合に使えそう。

 

その場合はapexやJSからS3にアップロード→パイプラインとジョブ作成 って感じかな。

 

ジョブは非同期処理なので、SNSとかとうまく組み合わせて

完了通知や完了後処理を行うと良さそう。

完了通知だとEメール、完了後処理となるとHTTP通知とかかな。

このエントリーをはてなブックマークに追加