Azure Data FactoryでSharePointのファイルをADLS Gen2にコピー

## やりたいこと

* SharePointOnline(SPO)の特定のフォルダ内にあるファイルをゴソっとAzure Data Lake Storage Gen2(ADLS Gen2)にコピーしたい
* [MSドキュメント](https://docs.microsoft.com/ja-jp/azure/data-factory/connector-sharepoint-online-list?tabs=data-factory)に1ファイルだけコピーする方法が紹介されていたので、これを参考にやってみます

## Azure AD にアプリケーションを登録

Azure Portalから、Azure Active Directory > アプリの登録 > 新規登録

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1104230/0d865a35-edba-d53d-f019-7b95ed62c966.png)

アプリケーションIDとテナントIDをコピーしておきましょう

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1104230/32a4af54-f11e-d34a-b5db-81d41347072e.png)

続いて、このアプリケーションのシークレットを発行します
証明書とシークレット > 新しいクライアントシークレット
クライアントシークレットが作成されたら、**値**をコピーします

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1104230/4a333d9c-7580-632a-a798-84ea6ff60d78.png)


## アプリケーションにSPOサイトのアクセス許可を付与

SharePoint上での作業です
アプリに権限を付与するために、以下のページを開きます
`<tenant_name>`と`<site_name>`は環境に合わせて変更します

`https://<tenant_name>.sharepoint.com/sites/<site_name>/_layouts/15/appinv.aspx`

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1104230/a1f98d51-448b-7523-9c1b-d7ccd270e42a.png)

`アプリID`に先程コピーしたIDを貼り付けて、参照ボタンを押すと`タイトル`に登録したアプリ名が自動で設定されます。`アプリ ドメイン`、`リダイレクト先のURL`、`権限の要求XML`は[冒頭のMSドキュメント](https://docs.microsoft.com/ja-jp/azure/data-factory/connector-sharepoint-online-list#prerequisites)の通り入力します

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1104230/745d4b42-5a08-4542-6dc4-dbe28cc5be0c.png)

信頼します

これで、Data FactoryからSPOにアクセスするために利用するアプリが作成できました

ちなみに、[SharePoint REST API](https://docs.microsoft.com/ja-jp/sharepoint/dev/sp-add-ins/working-with-folders-and-files-with-rest)に関するページを見ると、`https://{site_url}/_api/web/...`といった形でリクエストするURLが記載されていますが、`{site_url}`の部分が`<tenant_name>.sharepoint.com/sites/<site_name>`です

## Data Factoryでパイプラインを作成

### パイプラインの完成形

ここから長くなるので、先に完成形をお見せしておきます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1104230/6836400d-893b-f764-765d-5708d5a91714.png)


### Webアクティビティ：SPOのアクセストークンを取得

以下の通り設定します
`<app_id>`、`<app_secret>`、`<tenant_id>`、`<tenant_name>`は置き換えてください
ドキュメントに記載がありますが、`セキュリティで保護された出力`をONにしているのは、シークレットの値がログに出力されないようにするためです

* URL: https://accounts.accesscontrol.windows.net/<tenant_id>/tokens/OAuth/2
* メソッド: POST
* Headers: Content-Type:application/x-www-form-urlencoded
* 本文: grant_type=client_credentials&client_id=<app_id>@<tenant_id>&client_secret=<app_secret>&resource=00000003-0000-0ff1-ce00-000000000000/<tenant_name>.sharepoint.com@<tenant_id>
* セキュリティで保護された出力: チェックON

### Lookupアクティビティ：取得したいフォルダ内のファイル一覧を取得する

まずはSPOのデータセットを作成します
`ソースデータセット`で新規を押して`SharePoint Online リスト`を選択

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1104230/f4b2541a-0582-db5e-bd75-763e12e13124.png)

データセットが作成されたら、`リンクサービス`で新規を押します
テスト接続が成功することを確認しておきます

* テナント: アプリケーションのテナントID
* サービスプリンシパルID: アプリケーションID
* サービスプリンシパルキー: アプリケーションのクライアントシークレット

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1104230/a1be1873-ad2b-8a9f-9f86-b5311da9e0c5.png)

リンクサービスが作成できたら、`リスト名`でドキュメントを選択します

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1104230/2b6bf0bf-56fd-94a4-7e65-f111bbe90f26.png)

データのプレビューを押して、以下のようにドキュメント一覧が表示されたらOKです
私の環境では、`名前`という列にファイル名、`パス`という列に`/sites/<site_name>/Shared Documents/General/...`の形式でファイル名を除いたパスが表示されていました。このファイル名とパスを表す列名は後ほど使うのでコピーしておきましょう

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1104230/c7594517-c39d-85dd-d052-dbd9367e9b38.png)

やっと**Lookupアクティビティのソースデータセットが定義できました**

次に、特定のフォルダ内のファイルのみコピーしたいので、クエリを使ってフィルターします
SharePoint REST APIには色んな[クエリ操作](https://docs.microsoft.com/ja-jp/sharepoint/dev/sp-add-ins/use-odata-query-operations-in-sharepoint-rest-requests)が用意されていますが、今回は`$filter`クエリを利用します

**パスが特定の文字列ではじまる**という条件にします
startswithの1つめの引数が英語ではありませんが、シングルクォートなどは必要はありません

```
$filter=startswith(パス,'/sites/<site_name>/Shared Documents/General/...')
```

`startswith`のような関数は他にもあるので、他の条件を試したい場合は[コチラ](https://www.odata.org/documentation/odata-version-2-0/uri-conventions/#FilterSystemQueryOption)を参考にしてください

これでもう一度プレビューしてみましょう
今度は**対象フォルダのファイルだけ**が表示されていますね

### Foreachアクティビティ：ファイル一覧をパラメーターにループ処理

ここまでで半分ぐらい終わりました
まずは、Foreachアクティビティの`項目`を指定します。**ループ対象の配列**を指定するイメージです
`動的なコンテンツの追加`をクリックして、`@activity('Lookupアクティビティの名前').output.value`を入力しましょう
Lookupアクティビティの出力結果を確認したい場合は、Lookupアクティビティをデバッグ実行するとjson形式の出力が確認できます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1104230/2ad13795-6ac6-f00c-46ce-8b0790b79b82.png)

<details><summary>出力サンプル</summary><div>

```
{
    "count": 3,
    "value": [
        {
            "コンテンツタイプのID": "xxxxx",
            "更新日時": "2020-10-21T11:59:55Z",
            "名前": "xxxxx",
            "コンプライアンス資産ID": null,
            "タイトル": null,
            "Tags": null,
            "ExtractedText": null,
            "場所国地域": null,
            "場所都道府県": null,
            "場所市区町村": null,
            "場所郵便番号コード": null,
            "場所番地": null,
            "場所座標": null,
            "場所名前": null,
            "ID": 24,
            "コンテンツタイプ": "ドキュメント",
            "登録日時": "2020-10-21T11:59:55Z",
            "登録者Id": 10,
            "更新者Id": 10,
            "コピー元": null,
            "承認の状況": "0",
            "パス": "xxxxx",
            "チェックアウト先Id": null,
            "ウイルスの状態": "2628",
            "現在のバージョン": true,
            "Owshiddenversion": 1,
            "バージョン": "1.0"
        },
        {
            "コンテンツタイプのID": "xxxxx",
            "更新日時": "2020-10-21T11:59:56Z",
            "名前": "xxxxx",
            "コンプライアンス資産ID": null,
            "タイトル": null,
            "Tags": null,
            "ExtractedText": null,
            "場所国地域": null,
            "場所都道府県": null,
            "場所市区町村": null,
            "場所郵便番号コード": null,
            "場所番地": null,
            "場所座標": null,
            "場所名前": null,
            "ID": 25,
            "コンテンツタイプ": "ドキュメント",
            "登録日時": "2020-10-21T11:59:56Z",
            "登録者Id": 10,
            "更新者Id": 10,
            "コピー元": null,
            "承認の状況": "0",
            "パス": "xxxxx",
            "チェックアウト先Id": null,
            "ウイルスの状態": "387602",
            "現在のバージョン": true,
            "Owshiddenversion": 1,
            "バージョン": "1.0"
        },
        {
            "コンテンツタイプのID": "xxxxx",
            "更新日時": "2020-10-21T11:59:56Z",
            "名前": "xxxxx",
            "コンプライアンス資産ID": null,
            "タイトル": null,
            "Tags": null,
            "ExtractedText": null,
            "場所国地域": null,
            "場所都道府県": null,
            "場所市区町村": null,
            "場所郵便番号コード": null,
            "場所番地": null,
            "場所座標": null,
            "場所名前": null,
            "ID": 26,
            "コンテンツタイプ": "ドキュメント",
            "登録日時": "2020-10-21T11:59:56Z",
            "登録者Id": 10,
            "更新者Id": 10,
            "コピー元": null,
            "承認の状況": "0",
            "パス": "xxxxx",
            "チェックアウト先Id": null,
            "ウイルスの状態": "2683952",
            "現在のバージョン": true,
            "Owshiddenversion": 1,
            "バージョン": "1.0"
        }
    ],
    "effectiveIntegrationRuntime": "DefaultIntegrationRuntime (Japan East)",
    "billingReference": {
        "activityType": "PipelineActivity",
        "billableDuration": [
            {
                "meterType": "AzureIR",
                "duration": 0.016666666666666666,
                "unit": "DIUHours"
            }
        ]
    },
    "durationInQueue": {
        "integrationRuntimeQueue": 1
    }
}
```
</div></details>

### Foreach内のコピーアクティビティ（SharePointのAPIを利用してADLS Gen2にコピー）

**ソースデータセットを定義します**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1104230/9a427eb1-a357-ebf7-bb48-a6c8a94f76ed.png)

リンクサービスの定義
`ベースURL`は`https://<tenant_name>.sharepoint.com/sites/<site_name>/_api/web/`の形式**（末尾の"/"を忘れないようにしましょう）** [^1]

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1104230/f074614c-3819-3504-e2c0-18174a55bc63.png)

ここまで進めると、バイナリのデータセット定義が以下のようになっていると思います

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1104230/57358ba9-524e-e034-4935-42584500ba87.png)

ファイルのフルパスをパラメーターで指定したいので、パラメーターを追加します

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1104230/71164e6e-5073-6e0c-b9df-904add46f0eb.png)

そのパラメーターを使って`相対URL`を動的に指定します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1104230/31a1a223-30ab-1321-8f97-af5ec689aaf6.png)

```cmd:相対URLの設定
GetFileByServerRelativeUrl('@{uriComponent(dataset().FilePath)}')/$value
```

[GetFileByServerRelativeUrl](https://docs.microsoft.com/ja-jp/dotnet/api/microsoft.sharepoint.client.web.getfilebyserverrelativeurl?view=sharepoint-csom)はSharePoint REST APIのメソッドで相対URLを指定してファイルをダウンロードします。GetFileByServerRelativeUrlメソッドの引数に指定している`@{uriComponent(dataset().FilePath)}`の部分は、`FilePath`というパラメーターを[Data Factoryの関数](https://docs.microsoft.com/ja-jp/azure/data-factory/control-flow-expression-language-functions)`uriComponent`を使ってエンコードしています


Copyアクティビティの残りのソース定義を設定していきます
先程データセット定義で作成した`FilePath`というパラメーターがここに表示されています。Foreachでループ中の要素が持っているファイル名とファイルパスの情報を使ってファイルのフルパスを動的に指定します
`動的なコンテンツの追加`をクリックして、`@{item().パス}/@{item().名前}`と入力しましょう
ループ中の要素にアクセスする際は`item()`を使います
`item()`の後ろには、SPOのデータをプレビューした時にコピーしたファイル名とパスを表す列名を使います

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1104230/84972695-554f-d54c-88d4-6d351c94d7f7.png)

`要求メソッド`、`追加のヘッダー`はドキュメント通りです

* 要求メソッド: GET
* 追加のヘッダー: `@{concat('Authorization: Bearer ', activity('<Web-activity-name>').output.access_token)}`


**シンクデータセットを定義します**

こちらはADLS Gen2のデータセット定義で特に注意すべきポイントもないので詳細は割愛します。SPOのファイル名をそのまま使いたいケースがほとんどかと思うので、ソースデータセットと同じように、`FileName`のようなパラメーターを追加して、そのパラメーターに`@item().名前`を渡しておきましょう

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1104230/60034b58-6144-fc4e-2641-bcc7a492f539.png)

## さいごに

もっと簡単にできるのかと思っていましたが、思ったより苦戦しました…
他に良い方法があれば教えて下さいm(_ _)m

[^1]: 以下の説明を読むと、ベースURLと相対URLの間の"/"はData Factoryがつけてくれそうなもんですが、つけてくれなかったのでベースURLの末尾につけました<br>![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1104230/2479fd1d-88ab-ff66-0327-a14f876b2a4f.png)<br>