はじめに
この記事では、Qiita APIから自分が投稿した記事のView数を取得し、DBに保存するということをやっていきます。実現方法としては、Azure FunctionsのTimmer Trigger関数を使って、定期的に情報を取得し、Azure SQL Databaseに保存します。また、DBへの接続文字列などの情報はAzure KeyVaultに保存し、プログラムから参照するような構成とします。
開発環境
ローカルマシン(Mac:MacOS Catalina v10.15.4)で開発したものをAzureへデプロイする形で開発を行います。VS Codeの拡張機能であるAzure Functions for Visual Studio Codeを使用します。
Azure SQL Databaseの作成
はじめにデータを保存するためのSQL Databaseを作成しておきます。詳細な手順は省略しますがMicrosoft公式ドキュメントを参考に作成してください。できるだけお金がかからないように最小スペックで作成します。
- SQL Databaseのスペック(一部抜粋)
項目 | 値 |
---|---|
価格レベル | Basic |
ストレージ容量 | 2GB |
Azure Functionsにデプロイするプログラムの作成
Microsoft公式ドキュメント(クイック スタート:Visual Studio Code を使用して Azure で関数を作成する)の通りにローカル環境にプロジェクトを作成します。テンプレート選択のところはTimer Triggerを選択してください。今回はC#を使用して開発していきます。また、この記事では順を追ってプログラムを作成していきますが最終的なプログラムはGitHubで公開しています。
Qiita APIで記事のView数を取得する
テンプレートを生成できたところで、まずはQiita APIで記事のView数を取得する処理を書いていきます。テンプレートのファイルに指定したURIにAPIリクエストを送る関数GetJsonを定義します。指定するURIはQiitaの公式ドキュメントを参考に決定します。今回は自分が投稿した記事の一覧を取得するAPIを使用します。
- 自分が投稿した記事の一覧を取得するAPI
https://qiita.com/api/v2/users/[Qiitaのユーザー名]/items
しかし、このAPIをただ使用するだけでは情報は取得できません。一般に公開されていない情報(ユーザーに関する情報や記事のview数など)はアクセストークを付与したリクエストを送る必要があります。なのでQiita APIで情報を取得するために必要なアクセストークンを格納するクラスも別ファイルとして作成しておきます。アクセストークンを発行していない場合はユーザー設定から発行しておきます。
Qiita APIで記事の情報を取得するプログラム
usingSystem;usingMicrosoft.Azure.WebJobs;usingMicrosoft.Extensions.Logging;usingNewtonsoft.Json;usingSystem.Collections.Generic;usingSystem.Net.Http;usingSystem.Net.Http.Headers;usingSystem.Threading.Tasks;namespacekanazawa.Function{publicstaticclassget_qiita_views{[FunctionName("get_qiita_views")]publicstaticasyncvoidRun([TimerTrigger("0 0 * * * *")]TimerInfomyTimer,ILoggerlog){TimeZoneInfojstTimeZone=TZConvert.GetTimeZoneInfo("Tokyo Standard Time");DateTimeutcTime=DateTime.UtcNow;DateTimejstTime=TimeZoneInfo.ConvertTimeFromUtc(utcTime,jstTimeZone);log.LogInformation($"C# Timer trigger function executed at: {jstTime}");// Qiita APIのURLstringurl="https://qiita.com/api/v2/users/"+Parameter.getQiitaUserName()+"/items";// 投稿記事情報取得stringjson=awaitGetJson(url);}privatestaticasyncTask<string>GetJson(stringurl){varhttpClient=newSystem.Net.Http.HttpClient();// OAuth 2.0 Authorization Headerの設定httpClient.DefaultRequestHeaders.Authorization=newAuthenticationHeaderValue("Bearer",Parameter.getQiitaAccessToken());varrequest=newHttpRequestMessage(HttpMethod.Get,url);HttpResponseMessageresponse=awaithttpClient.SendAsync(request);stringresult=awaitresponse.Content.ReadAsStringAsync();returnresult;}}}
usingSystem;namespacekanazawa.Function{publicclassParameter{publicstaticstringgetQiitaAccessToken(){return"******";}publicstaticstringgetQiitaUserName(){return"******";}}}
取得結果はJson形式なので、これをデシリアライズ(C#のオブジェクトに変換)する必要があります。デシリアライズするためにはデータを格納するモデルクラスが必要となりますが、手動で作成するのはかなり面倒です。そのため以下のサイトで自動でモデルクラスを作成してもらいます。
curlコマンド等で別途Jsonを取得し、上記サイトでモデルクラスを作成しましょう。ここで1点注意点があります。上記サイトで生成されたモデルクラスには一部問題があり、私の場合は余計なフィールドがenum型で宣言されていました。この後実際にデシリアライズする際にエラーが出るので、その時でも良いですが
自分で確認して修正しましょう。
Jsonをデシリアライズする際に使用するモデルクラス
// <auto-generated />//// To parse this JSON data, add NuGet 'Newtonsoft.Json' then do://// using kanazawa.Function;//// var qiitaInformation = QiitaInformation.FromJson(jsonString);usingSystem;usingSystem.Collections.Generic;usingSystem.Globalization;usingNewtonsoft.Json;usingNewtonsoft.Json.Converters;namespacekanazawa.Function{publicpartialclassQiitaInformationModel{[JsonProperty("rendered_body")]publicstringRenderedBody{get;set;}[JsonProperty("body")]publicstringBody{get;set;}[JsonProperty("coediting")]publicboolCoediting{get;set;}[JsonProperty("comments_count")]publiclongCommentsCount{get;set;}[JsonProperty("created_at")]publicDateTimeOffsetCreatedAt{get;set;}[JsonProperty("group")]publicobjectGroup{get;set;}[JsonProperty("id")]publicstringId{get;set;}[JsonProperty("likes_count")]publiclongLikesCount{get;set;}[JsonProperty("private")]publicboolPrivate{get;set;}[JsonProperty("reactions_count")]publiclongReactionsCount{get;set;}[JsonProperty("tags")]publicTag[]Tags{get;set;}[JsonProperty("title")]publicstringTitle{get;set;}[JsonProperty("updated_at")]publicDateTimeOffsetUpdatedAt{get;set;}[JsonProperty("url")]publicUriUrl{get;set;}[JsonProperty("user")]publicUserUser{get;set;}[JsonProperty("page_views_count")]publicintPageViewsCount{get;set;}}publicpartialclassTag{[JsonProperty("name")]publicstringName{get;set;}[JsonProperty("versions")]publicobject[]Versions{get;set;}}publicpartialclassUser{[JsonProperty("description")]publicstringDescription{get;set;}[JsonProperty("facebook_id")]publicstringFacebookId{get;set;}[JsonProperty("followees_count")]publiclongFolloweesCount{get;set;}[JsonProperty("followers_count")]publiclongFollowersCount{get;set;}[JsonProperty("github_login_name")]publicstringGithubLoginName{get;set;}[JsonProperty("id")]publicstringId{get;set;}[JsonProperty("items_count")]publiclongItemsCount{get;set;}[JsonProperty("linkedin_id")]publicstringLinkedinId{get;set;}[JsonProperty("location")]publicstringLocation{get;set;}[JsonProperty("name")]publicstringName{get;set;}[JsonProperty("organization")]publicstringOrganization{get;set;}[JsonProperty("permanent_id")]publiclongPermanentId{get;set;}[JsonProperty("profile_image_url")]publicUriProfileImageUrl{get;set;}[JsonProperty("team_only")]publicboolTeamOnly{get;set;}[JsonProperty("twitter_screen_name")]publicobjectTwitterScreenName{get;set;}[JsonProperty("website_url")]publicstringWebsiteUrl{get;set;}}publicpartialclassQiitaInformation{publicstaticQiitaInformation[]FromJson(stringjson)=>JsonConvert.DeserializeObject<QiitaInformation[]>(json,kanazawa.Function.Converter.Settings);}publicstaticclassSerialize{publicstaticstringToJson(thisQiitaInformation[]self)=>JsonConvert.SerializeObject(self,kanazawa.Function.Converter.Settings);}internalstaticclassConverter{publicstaticreadonlyJsonSerializerSettingsSettings=newJsonSerializerSettings{MetadataPropertyHandling=MetadataPropertyHandling.Ignore,DateParseHandling=DateParseHandling.None,Converters={newIsoDateTimeConverter{DateTimeStyles=DateTimeStyles.AssumeUniversal}},};}}
モデルクラスが完成したらデシリアライズの処理を追記していきます。また、記事のview数は記事の一覧取得のAPIからは取得できないので、記事ごとの詳細を取得するAPIを発行する処理も追記します。
- 自分が投稿した記事ごとの詳細を取得するAPI
https://qiita.com/api/v2/items/[記事のID]
デシリアライズとView数取得処理を追記
usingSystem;usingMicrosoft.Azure.WebJobs;usingMicrosoft.Extensions.Logging;usingNewtonsoft.Json;usingSystem.Collections.Generic;usingSystem.Net.Http;usingSystem.Net.Http.Headers;usingSystem.Threading.Tasks;namespacekanazawa.Function{publicstaticclassget_qiita_views{[FunctionName("get_qiita_views")]publicstaticasyncvoidRun([TimerTrigger("0 0 * * * *")]TimerInfomyTimer,ILoggerlog){log.LogInformation($"C# Timer trigger function executed at: {jstTime}"); // Qiita APIのURLstringurl="https://qiita.com/api/v2/users/"+Parameter.getQiitaUserName()+"/items";// 投稿記事情報取得stringjson=awaitGetJson(url);// デシリアライズ時の設定varsettings=newJsonSerializerSettings{NullValueHandling=NullValueHandling.Ignore,MissingMemberHandling=MissingMemberHandling.Ignore};// デシリアライズList<QiitaInformationModel>models=JsonConvert.DeserializeObject<List<QiitaInformationModel>>(json,settings);// 各投稿記事のView数を取得stringgetViewsCountUrl;foreach(varmodelinmodels){getViewsCountUrl="https://qiita.com/api/v2/items/"+model.Id;model.PageViewsCount=JsonConvert.DeserializeObject<QiitaInformationModel>(awaitGetJson(getViewsCountUrl)).PageViewsCount;log.LogInformation($"title: {model.Title}");log.LogInformation($"views: {model.PageViewsCount}");}}privatestaticasyncTask<string>GetJson(stringurl){varhttpClient=newSystem.Net.Http.HttpClient();// OAuth 2.0 Authorization Headerの設定httpClient.DefaultRequestHeaders.Authorization=newAuthenticationHeaderValue("Bearer",Parameter.getQiitaAccessToken());varrequest=newHttpRequestMessage(HttpMethod.Get,url);HttpResponseMessageresponse=awaithttpClient.SendAsync(request);stringresult=awaitresponse.Content.ReadAsStringAsync();returnresult;}}}
取得したデータをAzure SQL Databaseに保存する
最初に作成したAzure SQL Databaseにデータを保存します。今回は予め以下のテーブルをDBに作成しておきました。
- qiita_items
記事の情報を格納するマスタテーブル
カラム名 | 型 |
---|---|
id | varchar(50) |
title | varchar(50) |
created_at | datetime |
- page_views_count
記事の時間ごとのview数を格納するトランザクションテーブル
カラム名 | 型 |
---|---|
id | varchar(50) |
counted_at | varchar(50) |
page_views_count | int(4) |
アクセストークンを格納したクラスにDBへの接続文字列を格納します。また、メインのクラスにDBへの接続・保存処理も記述していきます。
DBへの接続・保存処理を追記
usingSystem;usingMicrosoft.Azure.WebJobs;usingMicrosoft.Extensions.Logging;usingNewtonsoft.Json;usingSystem.Collections.Generic;usingSystem.Net.Http;usingSystem.Net.Http.Headers;usingSystem.Threading.Tasks;namespacekanazawa.Function{publicstaticclassget_qiita_views{[FunctionName("get_qiita_views")]publicstaticasyncvoidRun([TimerTrigger("0 0 * * * *")]TimerInfomyTimer,ILoggerlog){TimeZoneInfojstTimeZone=TZConvert.GetTimeZoneInfo("Tokyo Standard Time");DateTimeutcTime=DateTime.UtcNow;DateTimejstTime=TimeZoneInfo.ConvertTimeFromUtc(utcTime,jstTimeZone);log.LogInformation($"C# Timer trigger function executed at: {jstTime}"); // Qiita APIのURLstringurl="https://qiita.com/api/v2/users/"+Parameter.getQiitaUserName()+"/items";// 投稿記事情報取得stringjson=awaitGetJson(url);// デシリアライズ時の設定varsettings=newJsonSerializerSettings{NullValueHandling=NullValueHandling.Ignore,MissingMemberHandling=MissingMemberHandling.Ignore};// デシリアライズList<QiitaInformationModel>models=JsonConvert.DeserializeObject<List<QiitaInformationModel>>(json,settings);// 各投稿記事のView数を取得stringgetViewsCountUrl;foreach(varmodelinmodels){getViewsCountUrl="https://qiita.com/api/v2/items/"+model.Id;model.PageViewsCount=JsonConvert.DeserializeObject<QiitaInformationModel>(awaitGetJson(getViewsCountUrl)).PageViewsCount;log.LogInformation($"title: {model.Title}");log.LogInformation($"views: {model.PageViewsCount}");}// DB接続文字列の取得varconnectionString=Parameter.getConnectionString();// データ保存using(varconnection=newSqlConnection(connectionString)){// データベースの接続開始connection.Open();try{// マスタテーブルの更新チェックDatabase.checkMasterData(models,log,connection);// データを保存Database.saveData(models,jstTime,log,connection);}catch(Exceptionexception){log.LogInformation(exception.Message);throw;}finally{// データベースの接続終了connection.Close();}}}privatestaticasyncTask<string>GetJson(stringurl){varhttpClient=newSystem.Net.Http.HttpClient();// OAuth 2.0 Authorization Headerの設定httpClient.DefaultRequestHeaders.Authorization=newAuthenticationHeaderValue("Bearer",Parameter.getQiitaAccessToken());varrequest=newHttpRequestMessage(HttpMethod.Get,url);HttpResponseMessageresponse=awaithttpClient.SendAsync(request);stringresult=awaitresponse.Content.ReadAsStringAsync();returnresult;}}}
usingSystem;usingMicrosoft.Extensions.Logging;usingSystem.Collections.Generic;usingSystem.Data.SqlClient;usingSystem.Data;namespacekanazawa.Function{publicclassDatabase{// 新たに記事が投稿された場合はマスタテーブルを更新publicstaticvoidcheckMasterData(List<QiitaInformationModel>models,ILoggerlog,SqlConnectionconnection){using(vartransaction=connection.BeginTransaction()){try{using(varselectCommand=newSqlCommand(){Connection=connection,Transaction=transaction}){// SQLの準備selectCommand.CommandText=@"SELECT id FROM qiita_items";// SQLの実行vartable=newDataTable();varadapter=newSqlDataAdapter(selectCommand);adapter.Fill(table);// 存在フラグboolflg=false;foreach(varmodelinmodels){flg=false;for(inti=0;i<table.Rows.Count;i++){if(table.Rows[i]["id"].ToString().Equals(model.Id)){flg=true;break;}}if(flg==false){using(varinsertCommand=newSqlCommand(){Connection=connection,Transaction=transaction}){// SQLの準備insertCommand.CommandText=@"INSERT INTO qiita_items VALUES (@ID, @TITLE, @CREATED_AT)";insertCommand.Parameters.Add(newSqlParameter("@ID",model.Id));insertCommand.Parameters.Add(newSqlParameter("@TITLE",model.Title));insertCommand.Parameters.Add(newSqlParameter("@CREATED_AT",model.CreatedAt));// SQLの実行insertCommand.ExecuteNonQuery();log.LogInformation($"succeeded to insert master data: {model.Title}");}}}}// コミットtransaction.Commit();log.LogInformation("Committed");}catch{// ロールバックtransaction.Rollback();log.LogInformation("Rollbacked");throw;}}}// 各記事のview数を保存publicstaticvoidsaveData(List<QiitaInformationModel>models,DateTimejstTime,ILoggerlog,SqlConnectionconnection){using(vartransaction=connection.BeginTransaction()){try{foreach(varmodelinmodels){using(varcommand=newSqlCommand(){Connection=connection,Transaction=transaction}){// SQLの準備command.CommandText=@"INSERT INTO page_views_count VALUES (@ID, @COUNTED_AT, @PAGE_VIEWS_COUNT)";command.Parameters.Add(newSqlParameter("@ID",model.Id));command.Parameters.Add(newSqlParameter("@COUNTED_AT",jstTime.ToString("yyyy/MM/dd HH")));command.Parameters.Add(newSqlParameter("@PAGE_VIEWS_COUNT",model.PageViewsCount));// SQLの実行command.ExecuteNonQuery();log.LogInformation($"succeeded to insert data: {model.Title}");}}// コミットtransaction.Commit();log.LogInformation("Committed");}catch{// ロールバックtransaction.Rollback();log.LogInformation("Rollbacked");throw;}}}}}
usingSystem;namespacekanazawa.Function{publicclassParameter{publicstaticstringgetQiitaAccessToken(){return"******";}publicstaticstringgetQiitaUserName(){return"******";}publicstaticstringgetConnectionString(){return"******";}}}
ここで一度ローカルでテスト実行してみましょう。SQL Databaseの方で接続元IPアドレスを制限している場合は、ローカルPCからアクセスできるように設定した上でテスト実行します。うまくいったら一度Azureへデプロイしましょう。デプロイ時にAzure Functionsのリソースを作成できるので合わせて作成します。
Azure KeyVaultの利用
ここまでの実装でQiitaからデータを取得して、DBに保存することができます。しかし、DBへの接続情報などをソースコードの中に記述してしまっているため、セキュリティー的によろしくありません。ここではAzure KeyVaultにシークレットとして保存し、Azure Functionsから参照できるようにソースコードの改善とAzureの設定を入れていきます。
Azure KeyVaultの作成
Microsoft公式ドキュメント(チュートリアル:Linux VM と Python アプリを使用してシークレットを Azure Key Vault に格納する)を参考にAzure KeyVaultの作成とシークレットの格納を行います。今回は以下の3つのシークレットを格納します。
- Qiitaのアクセストークン
- Qiitaユーザー名
- SQL Databaseへの接続文字列
Azure KeyVaultの作成とシークレットの格納が完了したら、作成したAzure Functionsからシークレットを参照できるように権限を付与します。まずはAzure FunctionsのマネージドIDを有効化し、権限を付与する対象を作成します。作成後、Azure KeyVaultのアクセスポリシー設定画面からAzure FunctionsのマネージドIDに対してシークレットの取得権限を付与します。詳しいやり方はMicrosoft公式ドキュメント(App Service と Azure Functions の Key Vault 参照を使用する)を参照してください。
Azure Functionsの修正
参照先の設定が終わったので、Azure Functions側にシークレットを参照するように設定とソースコードの修正を入れていきます。Azure Functionsのアプリケーション設定を入れるとAzure Functionsの実行環境の環境変数にその値が反映されるのでソースコードから参照できるようになります。Microsoft公式ドキュメント(App Service と Azure Functions の Key Vault 参照を使用する)を参考にAzure Functionsに以下のアプリケーション設定を追加します。
- Qiitaのアクセストークン
- Qiitaユーザー名
- SQL Databaseへの接続文字列
上記ドキュメントにも記載されていますが、値にはAzure KeyVaultへの参照構文を入力します。参照構文は以下の形式です。
- @Microsoft.KeyVault(SecretUri=[参照したいシークレットのシークレット識別子])
シークレット識別子はAzure KeyVaultの該当シークレットの設定変更画面から取得できます。
ここまで準備ができたら後はソースコードを修正するだけです。各種秘匿情報を格納していたクラスを環境変数を参照するように修正します。
環境変数を参照するように修正
usingSystem;namespacekanazawa.Function{publicclassParameter{publicstaticstringgetQiitaAccessToken(){returnEnvironment.GetEnvironmentVariable("Qiita-Access-Token");}publicstaticstringgetQiitaUserName(){returnEnvironment.GetEnvironmentVariable("Qiita-User-Name");}publicstaticstringgetConnectionString(){returnEnvironment.GetEnvironmentVariable("ConnectionString");}}}
これでソースコード内に秘匿すべき情報を記述せずにQiitaからのデータ取得とDBへの保存ができるようになりました。実際にデプロイしてみて試してみましょう!今回の例では一時間に一回プログラムが実行されるようにスケジューーリングしているので、DBには一時間ごとの各記事のview数が保存されるはずです。実行のスケジューリングを変更したい場合はソースコードの以下の部分を変更して再デプロイしてください。
...publicstaticclassget_qiita_views{[FunctionName("get_qiita_views")]publicstaticasyncvoidRun([TimerTrigger("0 0 * * * *")]TimerInfomyTimer,ILoggerlog){...
TimerTrigger("0 0 * * * *")の引数で定義されているスケジューリング設定は左から秒、分、時間、日、月、曜日を表しています。*と記述することで毎秒、毎分といった意味となります。他にも表現方法はあるので興味のある方はMicrosoft公式ドキュメント(Azure Functions のタイマー トリガー)を参照してください。