WebSurfer's Home

トップ > Blog 1   |   ログイン
APMLフィルター

コレクションのデータアノテーション検証

by WebSurfer 2014年9月1日 18:25

ASP.NET MVC Web アプリケーションでユーザー入力の検証を行う場合、MVC2 以降ではデータアノテーション検証を標準で使用することができます。

データアノテーション検証

データアノテーション検証とは、簡単に言えば、入力モデルのクラスの public プロパティにアノテーション属性を付与することによって、クライアントから送信されてきたデータをアクションメソッドのパラメータにバインド(モデルバインディング)する際に、付与した属性に従って検証を実行し、その結果を ModelStateDictionary(Controller.ModelState プロパティで取得できます)に格納することです。

記事としてはちょっと古いのですが、Microsoft が提供するチュートリアルの [C#] #21. データ アノテーション検証コントロールでの検証 がまとまっていて理解しやすいと思います。← このチュートリアルはリンク切れになってしまいました。別の記事「データ注釈検証コントロールに (c#) を使用した検証」を紹介しておきます。

上記のサーバーサイドの検証に加えて、MVC3 以降ではクライアントサイドでの jQuery ライブラリによる検証もデフォルトで実装されるようになっています。

モデルに付与したアノテーション属性と EditorFor などの Html ヘルパーが連動して、レンダリングされる html 要素(例: <input type="text" ... />)に jQuery ライブラリによる検証機能が働くように必要な属性(例: data-val="true" など)を追加します。

クライアントサイドで検証結果 NG になるとエラーメッセージを表示し、submit はキャンセルされます。それゆえ、検証結果 NG のときはクライアントとサーバーの間の無駄なやりとりは起こりません。

さて、ちょっと前置きが長くなりましたが、ここでの本題、入力モデルが複合型のコレクションで入れ子になっているような複雑な場合、モデルバインディングとデータアノテーション検証(クライアントサイドを含む)をどう実装できるかという話を以下に書きます。

まずコレクションのモデルバインディングですが、それがうまく行われるようにするには、レンダリングされる html 要素の name 属性が連番のインデックスを含むようにします。

具体的には、name="prefix[index].Property" というパターンにします。prefix の部分にはアクションメソッドのパラメータ(仮引数)名が入ります。index は 0 から始まる連番です。数字の連続が途切れた場合は解析が停止し、0 から途切れる前までのデータが返されます。

マイクロソフト公式解説書の「プログラミング ASP.NET MVC」という本の 3.2.2 章に例がありますが、そこではビューで以下のようなコードで name 属性に連番のインデックスを持つ名前を設定しています。

var index = 0;
foreach (var country in Model.CountryList)
{
  <fieldset>
    <div> 
      <b>Name</b>
      <br />
      <input type="text" 
        name="countries[@index].Name" 
        value="@country.Name" />
      <br />
      <b>Capital</b>
      <br />
      <input type="text" 
        name="countries[@index].Details.Capital" 
        value="@country.Details.Capital" />
      <br />
      <b>Continent</b>
      <br />
      @{
        var id = String.Format(
                "countries[{0}].Details.Continent", 
                index++);
       }
       @Html.TextBox(id, country.Details.Continent)
       <br />
     </div>
   </fieldset>
}

上記のコードで countries はコントローラーの当該アクションメソッドの IList<Country> 型の仮引数の名前と一致させています。そうすることにより、クライアントから送信されてきたデータが正しくモデルバインディングされ、アクションメソッドに引き渡されます。

次に、これにデータアノテーション検証(クライアントサイドを含む)を追加するにはどうすればいいでしょう?

必要なアノテーション属性を入力モデルに追加するだけでもサーバーサイドでの検証は可能になります。しかし、それだけではクライアントサイドでの検証は動きません。

なぜなら、ASP.NET がレンダリングする html 要素には、クライアントサイドでの jQuery ライブラリによる検証に必要な属性(例: data-val="true" など)は追加されないからです。

検証に必要な属性が追加されるようにするにはビューに EditorFor のような Html ヘルパーを使うことです。これにより name 属性に設定する連番のインデックスも ASP.NET が自動的に追加してくれます。

モデル、コントローラー、ビューのコード例は以下の通りです。これを動かした結果が上の画像です。

(1) Model

モデルには普通に RequiredAttribute 他必要なアノテーション属性を追加すれば良いです。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.ComponentModel.DataAnnotations;

namespace Mvc4App.Models
{
  public class Country
  {
    public Country()
    {
      Details = new CountryInfo();
    }

    [Required(ErrorMessage = "{0} は必須")]
    [StringLength(15, ErrorMessage = "{0} は {1} 文字以内")]
    [Display(Name = "国名")]
    public String Name { get; set; }

    public CountryInfo Details { get; set; }
  }

  public class CountryInfo
  {
    [Required(ErrorMessage = "{0} は必須")]
    [StringLength(15, ErrorMessage = "{0} は {1} 文字以内")]
    [Display(Name = "首都")]
    public String Capital { get; set; }

    [Required(ErrorMessage = "{0} は必須")]
    [StringLength(15, ErrorMessage = "{0} は {1} 文字以内")]
    [Display(Name = "大陸")]
    public String Continent { get; set; }
  }

  public class ListCountriesViewModel
  {
    public ListCountriesViewModel()
    {
      CountryList = new List<Country>();
      SelectedCountries = new List<Country>();
    }
    public IList<Country> CountryList { get; set; }
    public IList<Country> SelectedCountries { get; set; }
  }
}

(2) Controller

アクションメソッドの仮引数の名前は、本にあったコード例の countries から countrylist に変更しています。これは、モデルの ListCountriesViewModel クラスのプロパティ名 CountryList と合わせる必要があるためです。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using Mvc4App.Models;

namespace Mvc4App.Controllers
{
  public class ComplexController : Controller
  {
    [ActionName("Countries")]
    public ActionResult ListCountriesForGet()
    {
      var defaultCountries = GetDefaultCountries();
      var model = new ListCountriesViewModel { 
                CountryList = defaultCountries };
      return View(model);
    }

    [HttpPost]
    [ActionName("Countries")]
    public ActionResult ListCountriesForPost(
            IList<Country> countrylist)
    {
      if (!ModelState.IsValid)
      {
        var model = new ListCountriesViewModel
        {
          CountryList = countrylist
        };

        return View(model);
      }
      else
      {
        var defaultCountries = GetDefaultCountries();

        var model = new ListCountriesViewModel
        {
          CountryList = defaultCountries,
          SelectedCountries = countrylist
        };

        return View(model);
      }            
    }

    private static IList<Country> GetDefaultCountries()
    {
      var countries = new List<Country>();
      countries.Add(new Country() { 
                Name = "Italy", 
                Details = new CountryInfo { 
                    Capital = "Rome", 
                    Continent = "Europe" } });
      countries.Add(new Country() { 
                Name = "Spain", 
                Details = new CountryInfo { 
                    Capital = "Madrid", 
                    Continent = "Europe" } });
      countries.Add(new Country() { 
                Name = "USA", 
                Details = new CountryInfo { 
                    Capital = "Washington", 
                    Continent = "NorthAmerica" } });
      return countries;
    }
  }
}

(3) View

EditorFor(m => m.CountryList[0].Name) からは name="CountryList[0].Name" という name 属性が生成されます。それゆえ、コントローラーのアクションメソッドの仮引数の名前を countrylist にしています。(注:モデルバインディングでは大文字小文字は区別されません)

@model Mvc4App.Models.ListCountriesViewModel

@{
  ViewBag.Title = "Countries";
  Layout = "~/Views/Shared/_Layout.cshtml";
}

@using (Html.BeginForm())
{
  <h2>Select your favorite countries</h2>

  for (int i = 0; i < Model.CountryList.Count; i++)
  {
    <fieldset>
    <div> 
      @Html.LabelFor(m => m.CountryList[i].Name)
      @Html.EditorFor(m => m.CountryList[i].Name)
      @Html.ValidationMessageFor(m => m.CountryList[i].Name)
      <br />
      @Html.LabelFor(m => m.CountryList[i].Details.Capital)
      @Html.EditorFor(m => m.CountryList[i].Details.Capital)
      @Html.ValidationMessageFor(m => m.CountryList[i].Details.Capital)
      <br />
      @Html.LabelFor(m => m.CountryList[i].Details.Continent)
      @Html.EditorFor(m => m.CountryList[i].Details.Continent)
      @Html.ValidationMessageFor(m => m.CountryList[i].Details.Continent)
      <hr />
    </div>
    </fieldset>
  }
    
  <input type="submit" value="Send" />
} 
  <hr />
    
  <h2>Countries submitted</h2>
  <ul>
  @foreach (var country in Model.SelectedCountries)
  {
    <li>@country.Name</li>
  }
  </ul>

@section Scripts {
  @Scripts.Render("~/bundles/jqueryval")
}

Tags: ,

Validation

Validator の Display="Dynamic" 時の注意点

by WebSurfer 2014年1月16日 17:45

ASP.NET の検証コントロール(RequiredFieldValidator, RegularExpressionValidator など)を使用して、Display プロパティを Dynamic に設定したときの注意点です。

Validator の Display="Dynamic" 時の注意点

この件は stackoverflow の記事 でも報告されています。ただ、実は自分はつい最近までこの問題は知らなかったです。(汗)

例えば、以下のように RequiredFieldValidator を配置して、その直後に Button を配置したとします。

<%@ Page Language="C#" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
 "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<script runat="server">
  protected void Button1_Click(object sender, EventArgs e)
  {
    if (Page.IsValid)
    {
      // 何らかの処置。
    }
  }
</script>

<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
  <title></title>
</head>
<body>
  <form id="form1" runat="server">
  <div>
    <asp:TextBox ID="TextBox1" runat="server"></asp:TextBox>
    <asp:RequiredFieldValidator ID="RequiredFieldValidator1" 
      runat="server" 
      ErrorMessage="エラーメッセージ"  
      ControlToValidate="TextBox1" 
      Display="Dynamic">
    </asp:RequiredFieldValidator>
    <asp:Button ID="Button1" 
      runat="server" 
      Text="Button" 
      OnClick="Button1_Click" />                
  </div>
  </form>
</body>
</html>

上記のコードでは以下の手順で問題を再現できます。

  1. TextBox は空のまま Button をクリックする。
  2. クライアント側で検証がかかり、ErrorMessage プロパティに設定した「エラーメッセージ」が表示される。
  3. TextBox に文字を入力して Button をクリックする。 ⇒ 「エラーメッセージ」は消えるがポストバックがかからない。
  4. 再度 Button をクリックする。 ⇒ ポストバックがかかる。

期待される動きは、上記 3 で「エラーメッセージ」が消えるとともにポストバックがかかるということのはずですが、そうはなりません。

上記のコードにおける解決策は、Button の直前に改行 ( <br /> ) を入れることです。そうしないとうまく行かない理由は以下の通りです。

Validator のエラーメッセージは html コードでは span 要素となり、JavaScript による検証結果により表示/非表示を切り替えています。

Display プロパティが Dynamic に設定されている場合は、当該 span 要素の style 属性を "display:none;" または "display:inline;" に設定することにより表示/非常時を切り替えます。

検証対象の TextBox にフォーカスを当ててからフォーカスを外す(例えば、TextBox に入力してから form を submit するために Button をクリックする)と、 そのタイミングで JavaScript による検証がかかるようになっています。

検証結果によって "display:inline;" が "display:none;" に(またはその逆に)書き換えられるので、エラーメッセージの部分のページレイアウトが変わることになります。

従って、上記のコードのように、RequiredFieldValidator の直後に Button が配置されているような場合、エラーメッセージが表示/非表示になる分だけ画面上でボタンが左右に移動します。

ボタンが移動すると、<input type="submit" ... /> タイプのボタンをクリックしたにもかかわらず form が submit されません。 即ち、ポストバックされないという期待に反する動作になります。

なお Button が動くのは左右でなくても、例えばエラーメッセージを p 要素に入れると上下に移動しますが、その場合でも同じくform は submit されません。

このことは、ASP.NET の検証コントロールを使った場合に限った話ではなく、html 要素と JavaScript だけでも再現できます。 以下のサンプルコードは、html 要素と JavaScript だけでこの問題(ボタンが動くと submit されない)を再現する例です。

サンプルコードを実際に動かして試すことができるよう 実験室 にアップしました。興味のある方は試してみてください。

<%@ Page Language="C#" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
 "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<script runat="server">

</script>

<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
  <title>WebSurfer's Page - 実験室</title>
  <script src="/scripts/jquery-1.8.3.min.js" type="text/javascript">
  </script>
  <script type="text/javascript">
  //<![CDATA[
    function toggleDisplay() {
      var validator = $('#RequiredFieldValidator1');
      if (validator.css("display") == "none") {
        validator.css("display", "inline");
      } else {
        validator.css("display", "none");
      }            
    }
  //]]>
  </script>
</head>
<body>
  <form id="form1" runat="server" 
    onsubmit="javascript:return confirm('Submit しますか?');">

  <div>
    <input name="textbox1" type="text" id="1extbox1" 
      onblur="javascript: toggleDisplay();" />
    <span id="RequiredFieldValidator1" 
      style="display:none;">エラーメッセージ</span>
    <input type="submit" name="button1" value="POST" 
      id="button1" />    
  </div>
  </form>
</body>
</html>

ちなみに、Display プロパティが Static に設定されている場合は、style 属性を "visibility:hidden;" または "visibility:visible" に設定するので、 表示/非表示を切り替えてもページのレイアウトは変わりません。結果、ボタンは動かないのでこの問題は起こりません。

Tags: ,

Validation

クライアント側での検証結果の表示

by WebSurfer 2013年8月1日 15:36
2017/3/31 追記
ASP.NET 4.5 でクライアント側での検証用スクリプトが大幅に変更されており、この記事の要である Page_Validators はインラインでページには埋め込まれませんが、WebResource.axd ハンドラでダウンロードされるスクリプトファイルに定義されており、以下の記事のコードをそのまま利用して「クライアント側での検証結果の表示」は可能です。

RequiredFieldValidator, RegularExpressionValidator 等の検証コントロールを利用してユーザー入力の検証を行う場合、クライアント側(ブラウザ)で行われた検証結果をチェックする方法を備忘録として書いておきます。

クライアント側での検証

検証コントロールをページに配置すると、デフォルト(即ち、EnableClientScript プロパティが true)では、クライアント側(ブラウザ)でユーザー入力を検証するためのスクリプトを ASP.NET が自動的に生成してくれます。

検証用のスクリプト本体は、検証コントロールのアセンブリに埋め込まれており、それを外部ファイルとして HTTP ハンドラ(WebResource.axd)を利用して取得する仕組みになっています。(ちなみに、インラインでページに埋め込まないのは、ブラウザでキャッシュできるようにするためです)

さらに、form 要素の onsubmit 属性に以下のようにスクリプトが設定されます。

onsubmit="javascript:return WebForm_OnSubmit();"

WebForm_OnSubmit メソッドの定義は ASP.NET が自動生成し、インラインで html コードに含めます。今回のサンプルコード(この記事の一番下にアップしたもの)の場合は以下のようなスクリプトが生成されます。

<script type="text/javascript">
//<![CDATA[
function WebForm_OnSubmit() {
  OnClientValidation();

  if (typeof(ValidatorOnSubmit) == "function" && 
      ValidatorOnSubmit() == false) return false;
  return true;
}
//]]>
</script>

上のコードで、if (typeof(ValidatorOnSubmit) から return true; までは、検証コントロールによって追加された検証用のスクリプトです。これにより、WebForm_OnSubmit メソッドは、クライアント側(ブラウザ)でのユーザー入力の検証結果が NG の場合 false を返すので、submit イベントがキャンセルされます(結果、ポストバックはかかりません)。

その前にある OnClientValidation(); は、Page_Load メソッドの中で RegisterOnSubmitStatement メソッドを使って登録した、自作のメソッドです。head 要素内にインラインで定義してあります。一番下のサンプルコードを参照してください。これが今回の記事のテーマです。

OnClientValidation メソッドで使っている Page_Validators は、ASP.NET が検証コントロールからレンダリングした span 要素の DOM の配列で、これに検証結果その他の情報が含まれています。今回のサンプルコードでは以下のようにインラインで定義されます。

Page_Validators の定義以外にもクライアント側(ブラウザ)での検証に必要なスクリプトがインラインで多々定義されています。詳細は、この記事の一番最後にアップしたサンプルコードを実行したときにレンダリングされる html ソースを見てください。

<script type="text/javascript">
//<![CDATA[
var Page_Validators =  new Array(
  document.getElementById("RequiredFieldValidator1"), 
  document.getElementById("RegularExpressionValidator1"), 
  document.getElementById("RequiredFieldValidator2"), 
  document.getElementById("RegularExpressionValidator2"), 
  document.getElementById("RequiredFieldValidator3"), 
  document.getElementById("CustomValidator1"));
//]]>
</script>

この Page_Validators を使って、全ての検証用コントロールのクライアント側(ブラウザ)での検証結果を取得し、alert を使って表示してみました。この記事の一番上の画像がその結果です。

なお、Page_Validators という名前は Microsoft の公式文書で公開されているわけではなく、将来、予告なしで変更される可能性もありますので注意してください。(ASP.NET 3.5, ASP.NET 4 は Page_Validators という名前であることを確認しましたが)

上の画像を表示したサンプルコードは以下の通りです。実際に動かして試すことができるよう 実験室 にアップしました。興味のある方は試してみてください。

<%@ Page Language="C#" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
 "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<script runat="server">

  protected void Button1_Click(object sender, EventArgs e)
  {
    // クライアント側での検証が OK となって初めてポスト
    // バックがかかり、このメソッドに制御が飛んでくる。
    // ブラウザで JavaScript が有効になっており、クライ
    // アント側で検証が行われている限り、Page.IsValid
    // が false になることはない("Page is NOT valid"
    // という文字が Label に表示されることはない)はず。
    if (Page.IsValid)
    {
      Label1.Text = "Page is valid";
    }
    else
    {
      Label1.Text = "Page is NOT valid";
    }
  }

  protected void Page_Load(object sender, EventArgs e)
  {        
    // RegisterOnSubmitStatement メソッドにより form 要素
    // の onsubmit 属性に以下のスクリプトが設定される。
    //
    // onsubmit="javascript:return WebForm_OnSubmit();"
    //
    // この WebForm_OnSubmit メソッドの中に、第三引数(以
    // 下の例では cstext)に渡したスクリプトが設定される。
    // これにより、ポストバックする直前にクライアントスク
    // リプトを起動し、必要があればポストバックをキャンセ
    // ルすることもできる。
       
    string csname = "OnSubmitScript";
    Type cstype = this.GetType();
    ClientScriptManager cs = Page.ClientScript;
    if (!cs.IsOnSubmitStatementRegistered(cstype, csname))
    {
      string cstext = "OnClientValidation();";
      cs.RegisterOnSubmitStatement(cstype, csname, cstext);
    }

    // 注:
    // Validator を Page に配置すると WebForm_OnSubmit
    // メソッドの中には検証用のクライアントスクリプトも
    // 追加される。順序は、まず上記 cstext に指定した
    // スクリプト、次に Validator の検証用スクリプトと
    // なる。
  }

  // CustomValidator のサーバー側での検証用
  protected void CustomValidator1_ServerValidate(
    object source, ServerValidateEventArgs args)
  {
    string membership = args.Value.ToLower();

    if (membership == "gold" || membership == "silver")
    {
      args.IsValid = true;
    }
    else
    {
      args.IsValid = false;
    }
  }
</script>

<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
  <title>WebSurfer's Page - 実験室</title>
  <script type="text/javascript">
  //<![CDATA[

    // CustomValidator のクライアント側での検証用
    function CustomValidator1_ClientValidate(sender, args) {
      var membership = args.Value.toLowerCase();
      if (membership === "gold" || membership === "silver") {
        args.IsValid = true;
      } else {
        args.IsValid = false;
      }
    }

    // クライアント側全 Validator の検証結果を取得。
    // Page_Validators は ASP.NET が Validator から
    // レンダリングした span 要素の DOM の配列。
    // 詳しくは html ソースに含まれる定義を参照。
    // 将来、Page_Validators と言う名前その他は予告
    // なしで変更される可能性もあるので注意。
    function OnClientValidation() {
      var msg = "クライアント側での検証結果:\n";
      for (var i = 0; i < Page_Validators.length; i++) {
        var validator = Page_Validators[i];
        var ctrl = 
          document.getElementById(validator.controltovalidate);
        if (ctrl != null) {
          if (validator.isvalid) {
            msg += "○: " + validator.id + "/" + 
              ctrl.id + "/\"" + ctrl.value + "\"\n";
          }
          else {
            msg += "×: " + validator.id + "/" + 
              ctrl.id + "/\"" + ctrl.value + "\"\n";
          }
        }
      }
      alert(msg);
    }

  //]]>
    </script>
</head>
<body>
  <form id="form1" runat="server">
  <table>
    <tr>
      <td>
        User Name
      </td>
      <td>
        <asp:TextBox ID="username" runat="server">
        </asp:TextBox>
      </td>
      <td>
        <asp:RequiredFieldValidator 
          ID="RequiredFieldValidator1" 
          runat="server" 
          ErrorMessage="ユーザー名は必須入力です。" 
          ControlToValidate="username" 
          ForeColor="Red" 
          Display="Dynamic">
        </asp:RequiredFieldValidator>
        <asp:RegularExpressionValidator 
          ID="RegularExpressionValidator1" 
          runat="server" 
          ErrorMessage=
            "半角アルファベットで 40 文字以内にしてください。" 
          ControlToValidate="username" 
          ForeColor="Red" 
          ValidationExpression="^[a-zA-Z''-'\s]{1,40}$" 
          Display="Dynamic">
        </asp:RegularExpressionValidator>
      </td>
    </tr>
    <tr>
      <td>
        Email
      </td>
      <td>
        <asp:TextBox ID="email" runat="server">
        </asp:TextBox>
      </td>
      <td>
        <asp:RequiredFieldValidator 
          ID="RequiredFieldValidator2" 
          runat="server" 
          ErrorMessage="メールアドレスは必須入力です。" 
          ControlToValidate="email" 
          ForeColor="Red" 
          Display="Dynamic">
        </asp:RequiredFieldValidator>
        <asp:RegularExpressionValidator 
          ID="RegularExpressionValidator2" 
          runat="server" 
          ErrorMessage=
            "メールアドレスの形式が正しくありません。" 
          ControlToValidate="email"
          ForeColor="Red" 
          ValidationExpression=
            "\w+([-+.']\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*" 
          Display="Dynamic">
        </asp:RegularExpressionValidator>
      </td>
    </tr>
    <tr>
      <td>
        Membership
      </td>
      <td>
        <asp:TextBox ID="membership" runat="server">
        </asp:TextBox>
      </td>
      <td>
        <asp:RequiredFieldValidator 
          ID="RequiredFieldValidator3" 
          runat="server" 
          ErrorMessage="メンバーシップは必須入力です。" 
          ControlToValidate="membership" 
          ForeColor="Red" 
          Display="Dynamic">
        </asp:RequiredFieldValidator>
        <asp:CustomValidator 
          ID="CustomValidator1" 
          runat="server" 
          ErrorMessage=
            "Gold または Silver でなければなりません。" 
          ControlToValidate="membership"
          ForeColor="Red" 
          OnServerValidate="CustomValidator1_ServerValidate" 
          Display="Dynamic" 
          ClientValidationFunction=
            "CustomValidator1_ClientValidate" >
        </asp:CustomValidator>
      </td>
    </tr>
  </table>
  <asp:Button ID="Button1" 
    runat="server" 
    Text="送信" 
    OnClick="Button1_Click" />
  <br />
  <asp:Label ID="Label1" runat="server">
  </asp:Label>
  </form>
</body>
</html>

Tags:

Validation

About this blog

2010年5月にこのブログを立ち上げました。主に ASP.NET Web アプリ関係の記事です。

Calendar

<<  2024年4月  >>
31123456
78910111213
14151617181920
21222324252627
2829301234
567891011

View posts in large calendar