以前ASP.Netで超簡単な掲示板を作りました。

これのDBの処理はEntityFrameworkと呼ばれるO/Rマッパーを利用しています。
ただ、作っていて不便な点が出てきます。それが、テーブル情報の変更時です。
ケース:投稿に名前を付けれるようにする
掲示板の仕様拡充で投稿に名前を付けれるようにするケースを考えてみます。
以前の記事にならってBoardDbContext.cs内のBoardEntityとBoardPostEntityにNameプロパティを追加してみます。
public class BoardEntity
{
[Key]
public int Id { get; set; }
[Required]
public string Title { get; set; }
[Required]
public string Text { get; set; }
[Required]
public string Name { get; set; }
public virtual ICollection<BoardPostEntity> Posts { get; set; }
}
public class BoardPostEntity
{
[Key]
public int Id { get; set; }
[Required]
public string Text { get; set; }
[Required]
public string Name { get; set; }
}
Nameを追加しました。これでNameカラムを持ったテーブルになるかと思いきやそうはいきません。
実際に実行して掲示板一覧画面に行ってみてください。
The model backing the ‘BoardDbContext’ context has changed since the database was created. Consider using Code First Migrations to update the database
と、例外が発生します。
記述の通り、EntityFrameworkではソース側の構成とデータべース側の構成に差異があると例外が発生するようになっています。
対応策は?
これに対して大きく2つの対策があります。
- データベース(テーブル)を消す
- データベース側の構成をソースに合わせる
1. データベース消す
とても単純です。EntityFrameworkはテーブルが存在すれば差異を比べてエラーを出しますが、存在しなければ自動で作ります。
なので既存のテーブルを消してやれば新たな構成でテーブルを作り直してくれます。
もちろんデメリットもあります。
既存のテーブルを消すので、既存のデータも一緒に消えて無くなります。
2. データベース側を合わせる
さて、データが消えるのは困る。という時はデータベース側を更新して差異をなくす必要があります。
EntityFrameworkではこういう時の為にMigrationという機能が提供されています。
DB Migration
かなり簡単ですので、さっそく使ってみます。
Tools → NuGet Package Manager → Package Manager Console
を選択します。
このような画面がおそらく下に出ます。(場所は設定によります…)
ここに「Enable-Migrations」と記述しエンターを押します。
PM> Enable-Migrations
そうすると自動でなにやら処理が行われて、プロジェクトに「Migrations」フォルダと「Configuration.cs」、「日付_InitialCretate.cs」という2つのファイルが生成されます。
InitialCreateはこんな感じです。
namespace TestBoard.Migrations
{
using System;
using System.Data.Entity.Migrations;
public partial class InitialCreate : DbMigration
{
public override void Up()
{
CreateTable(
"dbo.BoardEntities",
c => new
{
Id = c.Int(nullable: false, identity: true),
Title = c.String(nullable: false),
Text = c.String(nullable: false),
})
.PrimaryKey(t => t.Id);
CreateTable(
"dbo.BoardPostEntities",
c => new
{
Id = c.Int(nullable: false, identity: true),
Text = c.String(nullable: false),
BoardEntity_Id = c.Int(),
})
.PrimaryKey(t => t.Id)
.ForeignKey("dbo.BoardEntities", t => t.BoardEntity_Id)
.Index(t => t.BoardEntity_Id);
}
public override void Down()
{
DropForeignKey("dbo.BoardPostEntities", "BoardEntity_Id", "dbo.BoardEntities");
DropIndex("dbo.BoardPostEntities", new[] { "BoardEntity_Id" });
DropTable("dbo.BoardPostEntities");
DropTable("dbo.BoardEntities");
}
}
}
中身を見るとなんとなくわかるかもしれませんが、Upには今のデータベースの構成になるようなコードが記述されています。
DownにはUpの記述を打ち消すようなコードが記述されています。
今回必要なのは、これにNameを追加したものです。これにはAdd-Migrationを使用します。
Consoleに「Add-Migration AddBoardName」と入力し実行します。
PM> Add-Migration AddBoardName
なにやら処理が走って、「日付_AddBoardName」というファイルが生成されます。
中身は
namespace TestBoard.Migrations
{
using System;
using System.Data.Entity.Migrations;
public partial class AddBoardName : DbMigration
{
public override void Up()
{
AddColumn("dbo.BoardEntities", "Name", c => c.String(nullable: false));
AddColumn("dbo.BoardPostEntities", "Name", c => c.String(nullable: false));
}
public override void Down()
{
DropColumn("dbo.BoardPostEntities", "Name");
DropColumn("dbo.BoardEntities", "Name");
}
}
}
UpにはNameをAddColumnする内容、DownにはNameをDropColumnする内容が記述されています。
Add-Migrationを使う事で今のデータベースの内容と、ソース側の差異を抜き出してこのようにコードに変換してくれます。
Add-Migrationに渡すパラメータは変更の名前です。分かりやすい名前にしておいた方がいいです。今回はBoardにNameカラムを足したのでAddBoardNameとしておきました。
この段階ではまだ差分を抽出しただけでテーブルは変更されていません。
この変更をデータベースに反映するためには「Update-Database」を使用します。
PM> Update-Database
これで差異が反映されます。
具体的にどのような処理が行われたか確認したい場合は-Verboseパラメータをつけます。
PM> Update-Database -Verbose
こちらでは
Applying explicit migrations: [201602030629020_AddBoardName].
Applying explicit migration: 201602030629020_AddBoardName.
ALTER TABLE [dbo].[BoardEntities] ADD [Name] [nvarchar](max) NOT NULL DEFAULT ''
ALTER TABLE [dbo].[BoardPostEntities] ADD [Name] [nvarchar](max) NOT NULL DEFAULT ''
といった感じで表示されます。
この状態でServer Explorerでテーブルの内容を確認してみます。
たしかにNameが増えています。
再度、掲示板一覧を確認してみてもエラーも発生しません。
アプリ側の対応
DBにはNameが追加されたので、アプリ側の対応を行います。
掲示板投稿時
まずテストにNameを追加します。
/// <summary>
/// 掲示板の投稿のテスト
/// </summary>
[TestMethod]
public void PostCreate()
{
// ダミーデータの生成
var model = new BoardCreateModel {
Title = "題名",
Text = "本文",
Name = "名前"
};
var dummy = new BoardEntity { Id = 1, Title = model.Title, Text = model.Text, Name = model.Name };
var mocks = new Mocks( addDummy: dummy );
var controller = new BoardController(mocks.Context.Object);
var result = controller.Create(model) as RedirectResult;
Assert.IsNotNull( result );
// Addが呼ばれたかチェック
mocks.Boards.Verify( m => m.Add( It.Is<BoardEntity>( o => o.Title == model.Title && o.Text == model.Text && o.Name == model.Name ) ), Times.Once );
// SaveChangesがよばれたかチェック
mocks.Context.Verify( m => m.SaveChanges(), Times.Once );
Assert.AreEqual( result.Url, "/Board/Show/1" );
}
コンパイルエラーを確認して、モデルを更新します。
namespace TestBoard.Models
{
public class BoardPostModel
{
public string Text { get; set; }
public string Name { get; set; }
}
}
テストのエラーを確認して、処理を更新します。
[HttpPost]
public ActionResult Create(BoardCreateModel data)
{
int result = db_.Add( new BoardEntity {
Title = data.Title,
Text = data.Text,
Name = data.Name
} );
return Redirect("/Board/Show/" + result);
}
テストの成功を確認したら、ビューを更新します。
@model TestBoard.Models.BoardCreateModel
@{
Layout = "~/Views/Shared/_DefaultLayout.cshtml";
ViewBag.Title = "掲示板の追加";
}
@using (Html.BeginForm("Create", "Board", FormMethod.Post))
{
<p>タイトル:
@Html.TextBoxFor( m => m.Title )
</p>
<p>名前:
@Html.TextBoxFor( m => m.Name )
</p>;
<p>本文:</p>
@Html.TextAreaFor( m => m.Text )
<div>
<input type="submit" value="作成" />
</div>
}
ここまでできたらブラウザで投稿テストを行います。
投稿者の名前の表示
@model TestBoard.Models.BoardEntity
@{
Layout = "~/Views/Shared/_DefaultLayout.cshtml";
ViewBag.Title = Model.Title;
}
<div>
<p>@Model.Title</p>
<p>@Model.Name</p>
<p>@Model.Text</p>
</div>
@if ( Model.Posts != null )
{
<div>
@foreach ( var post in Model.Posts )
{
<div>
@post.Text
</div>
}
</div>
}
<div>
@using (Html.BeginForm("PostResponse", "Board", new { Id = Model.Id }, FormMethod.Post ) )
{
<div>
@Html.TextArea( "Text", "" )
</div>
<input type="submit" value="投稿" />
}
</div>
名前を追加するだけで行けます。
返信時に名前を付ける
テストを更新します。
/// <summary>
/// 返信の投稿のテスト
/// </summary>
[TestMethod]
public void PostResponse()
{
// DBのモックを用意する
var mocks = new Mocks(
new List<BoardEntity> {
new BoardEntity { Id = 1, Title = "A", Text = "a", Name = "名前" },
}
);
var postData = new BoardPostModel { Text = "投稿内容", Name = "名前" };
var controller = new BoardController(mocks.Context.Object);
var result = controller.PostResponse(1, postData ) as RedirectResult;
// データの追加がちゃんとされているかチェック
mocks.GetPosts(0).Verify( m => m.Add( It.Is<BoardPostEntity>( o => o.Text == postData.Text && o.Name == postData.Name ) ), Times.Once );
mocks.Context.Verify( m => m.SaveChanges(), Times.Once );
Assert.AreEqual( result.Url, "/Board/Show/1" );
}
エラーを確認して、モデルを更新します。
namespace TestBoard.Models
{
public class BoardPostModel
{
public string Text { get; set; }
public string Name { get; set; }
}
}
テストの失敗を確認して、コントローラを更新します。
[HttpPost]
public ActionResult PostResponse( int id, BoardPostModel data )
{
db_.PostResponse( id, new BoardPostEntity {
Text = data.Text,
Name = data.Name
} );
return Redirect("/Board/Show/" + id);
}
テストが通ったらビューを更新します。
@model TestBoard.Models.BoardEntity
@{
Layout = "~/Views/Shared/_DefaultLayout.cshtml";
ViewBag.Title = Model.Title;
}
<div>
<p>@Model.Title</p>
<p>@Model.Name</p>
<p>@Model.Text</p>
</div>
@if ( Model.Posts != null )
{
<div>
@foreach ( var post in Model.Posts )
{
<div>
@post.Text
</div>
}
</div>
}
<div>
@using (Html.BeginForm("PostResponse", "Board", new { Id = Model.Id }, FormMethod.Post ) )
{
<p>名前:
@Html.TextBox( "Name", "" )
</p>
<div>
@Html.TextArea( "Text", "" )
</div>
<input type="submit" value="投稿" />
}
</div>
返信の名前を表示
ビューを更新します。
@model TestBoard.Models.BoardEntity
@{
Layout = "~/Views/Shared/_DefaultLayout.cshtml";
ViewBag.Title = Model.Title;
}
<div>
<p>@Model.Title</p>
<p>@Model.Name</p>
<p>@Model.Text</p>
</div>
@if ( Model.Posts != null )
{
<div>
@foreach ( var post in Model.Posts )
{
<p>名前: @post.Name</p>
<div>
@post.Text
</div>
}
</div>
}
<div>
@using (Html.BeginForm("PostResponse", "Board", new { Id = Model.Id }, FormMethod.Post ) )
{
<p>名前:
@Html.TextBox( "Name", "" )
</p>
<div>
@Html.TextArea( "Text", "" )
</div>
<input type="submit" value="投稿" />
}
</div>
これで掲示板に名前が追加されました。
任意のマイグレーションへ更新する
Update-Databaseすると最新のものへ更新されますが、パラメータを追加する事で任意の時点のものを指定することが出来ます。
PM> Update-Database -TargetMigration:InitialCreate
TargetMigration:の後ろに任意の更新の名前を指定します。
これによって、各マイグレーションのUpメソッド/Downメソッドが呼ばれてテーブルの状態が変更されます。
この例だとAddBoardNameのDownメソッドが呼ばれてNameカラムが消えます。
注意点
カラムやテーブルの追加は問題は出にくいと思いますが、削除した場合はそのカラムやテーブルのデータも一緒に消えてしまいます。
先ほどの「Update-Database -TargetMigration:InitialCreate」した後に「Update-Database」で最新にしたとしても、投稿されたNameの内容は戻ってきません。