[ASP.NET MVC] Controller から Json を Camel Case でシリアライズして返す

public class User {
  public string Id { get; set; }
  public string Name { get; set; }
}

public class DefaultController : Controller
{
  [HttpGet]
  public ActionResult GetPerson()
  {
    IEnumerable<User> users;

    // DB からとってきたりする

    return Json(users.ToArray(), JsonRequestBehavior.AllowGet);
  }
}

このとき、Response の Json のプロパティ名はクラスのプロパティ名が引き継がれるので Pascal Case になってしまう。

[
  {
    Id = 'hoge',
    Name = 'fuga'
  },
  ...
]

JavaScript 側では Camel Case で処理したいことが多いのでイマイチだ。 簡単な方法は 2 つある。まずは匿名オブジェクトに変換してから返すというもの。

var json = Json(users.Select(x => new
  {
    id = x.Id,
    name = x.Name
  }).ToArray(), JsonRequestBehavior.AllowGet);
return json;

もう一つは JsonConvert を使うというもの。

var jsonSerializerSetting = new JsonSerializerSettings
  {
    ContractResolver = new CamelCasePropertyNamesContractResolver()
  });
var json = JsonCovert.SerializeObject(users.ToArray(), jsonSerializerSetting)
return json;

ちなみに、 ApiController なら Web アプリ単位で設定を持てるらしい (Application_Start() で指定する)。

参考

画像の解像度を変換する

【注意】画像エンコードのことは全然詳しくないので、本質的に正しいやり方なのか不明。

System.Drawing.Image で読み込んで .SetResolution してもいいのだが、

var image = Image.FromFile(path);
image.SetResolution(dpiX, dpiY);

System.Drawing は ASP.NET で非推奨らしい (メモリリークするとかいう噂) ので、 Windows Imaging Components を使う。

void Encode(Stream fromBitmapStream, Stream toBitmapStream, double dpiX, double dpiY)
{
  var decoder = BitmapDecoder.Create(fromBitmapStream, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.Default);
  var frame = decoder.Frames.First();

  var stride = frame.PixelWidth * 4; // 1 ピクセルあたり 4 バイト
  var pixelData = new byte[frame.PixelHeight * stride];
  frame.CopyPixels(pixelData, stride, 0);

  var source = BitmapSource.Create(frame.PixelWidth, frame.PixelHeight, dpiX, dpiY, frame.Format, frame.Palette, pixelData, stride);

  var encoder = new JpegBitmapEncoder() { Frames = { BitmapFrame.Create(source) } };
  encoder.Save(toBitmapStream);
}

PresentationCore と WindowsBase の参照が必要。で using System.Windows.Media.Imaging; する。 ちなみに BitmapDecoder とかいってるけど Jpeg でも可。

参考

2016年に読んだ本から再読したいものを5冊挙げる

去年に引き続き。


自分の中に毒を持て―あなたは“常識人間"を捨てられるか (青春文庫)

不連続的に人生を変えるレベルの一冊。迷ったら困難な方を選べ。困難は、経験して必ずしも成長するわけじゃない。困難を味わうことが人生の醍醐味だ。
……なんて書いていたかどうか覚えていないが、この本を読んで、自分でも思索に耽った結果、そう思っている。 

ファシリテーションの教科書: 組織を活性化させるコミュニケーションとリーダーシップ

 ファシリテーションを体系的に解説。迷ったときに読む。 

エンジニアのための図解思考 再入門講座

図解思考の技術というかそういう話ももちろんだけど、文章の質が異様に高いので、その点でも勉強になる。 

レガシーコード改善ガイド

これは俺たちの教科書だ。常に机に置いておきたい。

ひとり飲み飯 肴かな (NICHIBUN BUNKO)

既にめっちゃ再読してる。すいません。


去年よりもビジネス書をよく読んだ。小説も増やしたい。哲学書はあんまり読まなくなったなぁ。

定期的に GitHub API を叩いて Slack にポストしたい (3)

最終回

メッセージを Slack に投げる

function sendToSlack(message, attachments) {
  var url = "https://slack.com/api/chat.postMessage";
  var token = "token" // Slack のアクセストークン
  var channel = "#channel"; // 投稿する先のチャンネル
  var text = message; // 本文
  var username = "GitHub"; // Slack に投稿する BOT の名前
  var parse = "full"; // URL エスケープする
  var icon_emoji = ":emoji:"; // emoji 名
  
  var payload = {
    "token" : token,
    "channel" : channel,
    "text" : text,
    "username" : username,
    "parse" : parse,
    "icon_emoji" : icon_emoji,
    "attachments" : JSON.stringify(attachments)
  };
  
  var method = "post";
  var params = {
    "method" : method,
    "payload" : payload
  };
  
  var response = UrlFetchApp.fetch(url, params);
}

各 PR の情報を全部 message として組み立ててもいいのだけど attachment を使うとよりカッコよく一覧化できる。 ここ で試せる!

// 各 PR を attachment として配列で返す。
function createAttachments(pulls) {      
  var attachments = [];
  for (var i = 0; i < pulls.length; i++) {
    var pull = pulls[i];
    
    // GAS はいまのところ yyyy/mm/dd hh:mm:ss.mmm じゃないとパースできないのでミリ秒をくっつける。
    var createdAtDate = new Date(pull["createdAt"].replace("Z", ".000Z"));
    var createdAtString = 
      (createdAtDate.getMonth() + 1) + "/" + 
      createdAtDate.getDate() + " " +
      ("0" + createdAtDate.getHours()).slice(-2) + ":" +
      ("0" + createdAtDate.getMinutes()).slice(-2);
    
    var attachment = {
      "color": "#000000",
      "author_name": pull["user"],
      "author_icon": pull["avatar_url"],
      "title": pull["title"],
      "title_link": pull["url"],
      "footer": pull["commentCount"] + " comments ( +" + pull["newCommentCount"]  +" ), created at " + createdAtString,
    };
    
    attachments.push(attachment);
  }
  
  return attachments;
}

で、 PR 取得したらメッセージ付けて Slack に流す。

function notify() {
  var pulls = getPulls();
  if (pulls.length == 0) {
    return;
  }
  
  var message = pulls.length + " 個の PR が更新されました。"
  var attachments = createAttachments(pulls);
  
  sendToSlack(message, attachments);
}

cron 的な設定

解説や注意点など

  • チャンネルじゃなくて DM に送りたいときは # じゃなくて @ にするらしい。
  • Google Apps Script の Date は生 js と微妙に仕様が違うようで、コメントにも書いている以外にも色々ありそう。
  • attachment で日時を表示したいとき、自前で文字列にくっつけなくても ts に UNIX timestamp を指定すると出してくれるのだけど「Dec 29th at 7:44 PM」とかで表示されて我々にとって直感的じゃないのでやめた。
  • Slack の API はドキュメントが充実してるのでとっつきやすいですね。

定期的に GitHub API を叩いて Slack にポストしたい (2)

前回 では PR 一覧を取得したが、ラベルでの絞込やコメントが追加されたかどうかを判定できていなかった。

PR のラベル、コメント数を取得

  • PR 取得の API ではラベルとコメントを取得できないので別途 API をたたく。
/// ある PR に付与されたラベルを取得する
function getLabels(number, owner, repo, options) {
  var url = "https://api.github.com/repos/" + owner + "/" + repo + "/issues/" + number + "/labels?per_page=100";
  var response = UrlFetchApp.fetch(url, options);
  var json = JSON.parse(response.getContentText());
  
  var labels = [];
  for (var i = 0; i < json.length; i++) {
    var hash = json[i];
    labels.push(hash["name"]);
  }
  
  return labels;
}

// コメント数を取得
function countComments(number, owner, repo, options) {
  var url1 = "https://api.github.com/repos/" + owner + "/" + repo + "/issues/" + number + "/comments?per_page=200";
  var response1 = UrlFetchApp.fetch(url1, options);
  var comments1 = JSON.parse(response1.getContentText());
    
  var url2 = "https://api.github.com/repos/" + owner + "/" + repo + "/pulls/" + number + "/comments?per_page=200";
  var response2 = UrlFetchApp.fetch(url2, options);
  var comments2 = JSON.parse(response2.getContentText());
  
  return comments1.length + comments2.length;
}

前回取得時のコメント数を保存

  • コメント件数を覚えておいて、前回の件数より増えていたら通知対象にする、などに使う。
// PR 単位にコメント件数を PropertiesService で覚えておき、前回実行時からの差分を取得する。
function getUpdatedInfo(number, commentCount) {

  var key = number;
  var oldPull = JSON.parse(PropertiesService.getScriptProperties().getProperty(key));
  
  // 新しく作成された、またはコメントが追加された PR 
  var isCreated = oldPull == null;
  if(isCreated || commentCount > oldPull.commentCount) {
    var value = JSON.stringify({ 
      "commentCount" : commentCount
    });
    PropertiesService.getScriptProperties().setProperty(key, value);
    
    // 新しく作成された PR
    if(isCreated) {
      return {
        "isCreated": true,
        "newCommentCount": commentCount
      };
    }
    
    // コメントが追加された PR
    return {
      "isCreated": false,
      "newCommentCount": commentCount - oldPull.commentCount
    };
  }
    
  // 既存でコメントが追加されていない PR
  return {
    "isCreated": false,
    "newCommentCount": 0
  };
}

解説や注意点など

  • ラベルの取得はそのまんま。 PR は issue の一種という扱いらしく、 issue の API は PR でも使える。らしい。
  • コメントは、本文へのコメント(issues)とレビューコメント(pulls)で別に計算されるらしいのでそれぞれで取得する。
  • PropertiesService を使うと key-value でデータを write/read できる。この値は、画面からも確認できて、 Google Apps Script の「ファイル - プロジェクトのプロパティ - スクリプトのプロパティ」で表示される。素敵!

定期的に GitHub API を叩いて Slack にポストしたい (1)

発端

  • GitHub でレビューコメントきたら Slack に通知ほしい
  • かといって全部のコメント通知されてもウザい
  • 定期的に GitHub をみにいってコメントが増えてたら Slack に投げる、みたいなの bot を作ろう
  • 全3回の予定。

調べた

  • GitHub Slack」で検索すると大体「Node.js + Hubot 安定」みたいな記事ばっかりヒットする。でもサーバー立てたりダルい
  • Google Apps Script って手があるらしい
  • Google Spreadsheet でマクロ書くときに使うアレ
  • ほぼ js
  • 単体でも組めて cron 的に使える
  • Google Drive -> 新規 -> その他 -> Google Apps Script (初回は「アプリを追加」で追加する必要あり)

GitHub API をたたく

  • 今回は GitHub から PR を取得するだけ
  • ラベルの絞り込み、コメントの取得、 Slack に投げるところは次回以降
  • ラベルは何に使ってるかっていうと、投げるチャンネルを決めるため。チーム内の PR には特定のラベルをつける、という運用をしていて、拾った PR はチームのチャンネルに投げる想定。
function getPulls() {
  var owner = "owner";
  var repo = "repo";
  var token = "accessToken" // GitHub で発行
  
  var options =
  {
    "method" : "get",
    "headers" : {
      "Authorization": "token " + token 
    }
  };
  
  // open な PR を全て取得して、そのあとラベルで絞り込む。
  // per_page を指定しないと 30 件しか取れない。
  var url = "https://api.github.com/repos/" + owner + "/" + repo + "/pulls?per_page=100";
  var response = UrlFetchApp.fetch(url, options);
  var json = JSON.parse(response.getContentText());

  var results = [];
  for (var i = 0; i < json.length; i++) {
    var hash = json[i];
    
    // ラベルの絞込をここでやる
    var labels = getLabels(hash["number"], owner, repo, options);
      if (hash && labels.indexOf(targetLabel) >= 0) {
      var commentCount = countComments(hash["number"], owner, repo, options);
      
      // 新しく追加された、またはコメントが追加されたもののみ対象とする。
      var updatedInfo = getUpdatedInfo(hash["number"], commentCount);
      var isUpdated = updatedInfo.isCreated || updatedInfo.newCommentCount > 0
      if (!isUpdated) {
        continue;
      }
      
      // reviewer requests の API は preview なのでまだ使わない
      //var reviewers = getReviewers(hash["number"], owner, repo, options);
      
      results.push({
        "url": hash["html_url"],
        "title": hash["title"],
        "user": hash["user"]["login"],
        "createdAt": hash["created_at"],
        "avatar_url": hash["user"]["avatar_url"],
        "number": hash["number"],
        "commentCount": commentCount,
        "newCommentCount": updatedInfo.newCommentCount
      });
    }
  }
  
  return results;
}

解説とか注意点とか

TransactionScope のネスト

System.Transactions.TransactionScope は Complete を呼ばない限り Dispose されたときにロールバックしてくれる。 テストとか例外処理とかで便利だ。

using (var ts = new TransactionScope())
{
  // DB に書き込み

  ts.Complete();
}

ネストしてもつかえる。このときネスト内で new された TransactionScope すべてが Complete されてはじめてコミットされる。

void WriteParent(string connectionString) {
  using (var ts = new TransactionScope())
  {
    WriteChild1(); // この時点ではコミットされない

    WriteChild2(); // まだ

    ts.Complete(); // ここでようやくコミット
  }
}

void WriteChild1() {
  using (var ts = new TransactionScope())
  {
    // 書き込み

    ts.Complete();
  }
}

void WriteChild2() {
  using (var ts = new TransactionScope())
  {
    // 書き込み

    ts.Complete();
  }
}

ここで、子の TransactionScope が Complete されずに Dispose されるとその時点でロールバックされるので、親で Complete しようとすると TransactionAbortedException が発生してしまう。 例えば子で以下のように「書き込みが発生するのは特定の場合だけだから、そうじゃなかったら Complete 呼ばない」とかすると NG。

  using (var ts = new TransactionScope())
  {
    if(shouldUpdate) 
    {
      // 書き込み

      ts.Complete();
    }
  }

(TransactionScope はコンストラクターで新規に作るか既存を流用するを指定できる (TransactionScopeOption) 。このへんの指定で挙動が変わるかも?)

参考

トランザクション スコープを使用した暗黙的なトランザクションの実装