からめもぶろぐ。

SharePoint が得意なフレンズなんだね!すごーい!

SharePoint で SharePoint じゃないページを作る

この記事は「Office 365 Advent Calendar 2016」の参加記事です。

www.adventar.org


SharePoint は標準でもそこそこそれなりに使えるのですが、やはりデザインをカスタマイズしたいという要望は多いです。でも SharePoint のスタイル シート構造を理解してカスタマイズするのは結構大変ですし、Bootstrap のような CSS フレームワークを使うこともできません。カスタマイズに時間をかけるならいっそ最初から作ってしまえ!となってしまいます。ということで、今回は SharePoint 上で SharePoint じゃないページを作ってみたいと思います。*1

マスター ページをダウンロードする

発行機能が有効になっているサイトではデザイン マネージャーから最低限のマスター ページを作成してダウンロードすることができます。対象のサイトで発行機能が有効になっていない場合は、SharePoint Online で既定で作成される検索サイト (https://***.sharepoint.com/search) を表示してください。検索サイトは発行機能が有効になっていますので、デザイン マネージャーを使用できます。

f:id:karamem0:20161202231756p:plain

余談ですが、発行機能はよほどの理由がない限り無効にしておくことをお勧めします。UI のサポートは受けられませんが、既定のマスター ページを変更したり、異なるマスター ページを使用するサイト ページを作るのに発行機能は必要ありません。

適当な名前でマスター ページを作成したら、マスター ページ ギャラリーからダウンロードして、対象のサイトにアップロードします。

サイト ページを作成する

空のサイト ページを作成します。MasterPageFile を "~masterurl/default.master" から先ほどアップロードしたマスター ページに変更します。

<%@ Page Language="C#" MasterPageFile="~sitecollection/_catalogs/masterpage/custom.master" inherits="Microsoft.SharePoint.WebPartPages.WebPartPage, Microsoft.SharePoint, Version=16.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %>

<%@ Assembly Name="Microsoft.Web.CommandUI, Version=16.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %>
<%@ Register TagPrefix="SharePoint" Namespace="Microsoft.SharePoint.WebControls" Assembly="Microsoft.SharePoint, Version=16.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %>
<%@ Register TagPrefix="Utilities" Namespace="Microsoft.SharePoint.Utilities" Assembly="Microsoft.SharePoint, Version=16.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %>
<%@ Register TagPrefix="WebPartPages" Namespace="Microsoft.SharePoint.WebPartPages" Assembly="Microsoft.SharePoint, Version=16.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %>

<asp:Content ContentPlaceHolderID="PlaceHolderPageTitle" runat="server">
</asp:Content>
<asp:Content ContentPlaceHolderID="PlaceHolderAdditionalPageHead" runat="server">
</asp:Content>
<asp:Content ContentPlaceHolderID="PlaceHolderSearchArea" runat="server">
</asp:Content>
<asp:Content ContentPlaceHolderID="PlaceHolderPageDescription" runat="server">
</asp:Content>
<asp:Content ContentPlaceHolderID="PlaceHolderMain" runat="server">
</asp:Content>

エクスプローラーまたは SharePoint Designer からアップロードします。

f:id:karamem0:20161204231030p:plain

リボンだけの空白のページが表示されます。

マスター ページを編集する

Bootstrap を設定してみましょう。<head> の適当な位置に以下の記述を追加します。

<SharePoint:CssRegistration name="https://ajax.aspnetcdn.com/ajax/bootstrap/3.3.7/css/bootstrap.min.css" runat="server" />
<SharePoint:ScriptLink language="javascript" name="https://ajax.aspnetcdn.com/ajax/jquery/jquery-3.1.1.min.js" runat="server" />
<SharePoint:ScriptLink language="javascript" name="https://ajax.aspnetcdn.com/ajax/bootstrap/3.3.7/bootstrap.js" runat="server" />

既定のリボンの記述である <div id="ms-designer-ribbon"> および <wssucw:Welcome> はサクッと消してしまい、代わりに Bootstrap のナビゲーション バーを追加します。

<nav class="navbar navbar-inverse">
    <div class="container-fluid">
        <div class="collapse navbar-collapse">
            <p class="navbar-text navbar-right"><span id="current-user"></span></p>
        </div>
    </div>
</nav>

現在のユーザー名を表示するスクリプトを追加します。最低限のマスター ページでは SP.js を読み込んでいますので、JSOM を使用できます。

<script type="text/javascript">
    SP.SOD.executeFunc("sp.js", "SP.ClientContext", function () {
        var ctx = new SP.ClientContext.get_current();
        var web = ctx.get_web();
        var user = web.get_currentUser();
        ctx.load(user);
        ctx.executeQueryAsync(function () {
            $('#current-user').text(user.get_title());
        });
    });
</script>

マスター ページをアップロードします。
サイトにアクセスするとマスター ページで設定した Bootstrap のナビゲーションが表示されていることがわかります。

f:id:karamem0:20161204231034p:plain

サイト ページを編集する

リストの一覧を出してみましょう。JSOM を使ってリストのデータを読み込みます。

<asp:Content ContentPlaceHolderID="PlaceHolderMain" runat="server">
    <div class="container">
        <div class="table-responsive">
            <table id="table" class="table">
                <thead>
                    <tr>
                        <th>Id</th>
                        <th>Name</th>
                    </tr>
                </thead>
                <tbody>
                </tbody>
            </table>
        </div>
    </div>
    <script type="text/javascript">
        SP.SOD.executeFunc("sp.js", "SP.ClientContext", function () {
            var ctx = new SP.ClientContext.get_current();
            var web = ctx.get_web();
            var list = web.get_lists().getByTitle("ドキュメント");
            var items = list.getItems("");
            ctx.load(items, "Include(Id, File)");
            ctx.executeQueryAsync(function () {
                var collection = items.getEnumerator();
                while (collection.moveNext()) {
                    var item = collection.get_current();
                    var id = item.get_id();
                    var title = item.get_file().get_name();
                    $("#table tbody").append("<tr><td>" + id + "</td><td>" + title + "</td></tr>");
                }
            });
        });
    </script>
</asp:Content>

できました!簡単ですね。

f:id:karamem0:20161204231037p:plain

まとめ

今回は JSOM を使用する構成にしたので、最小限のマスター ページから作成しましたが、REST API だけを使うのであれば、SP.js を使う必要もないので、まったく新規のマスター ページからも作成できます。REST API であれば、SharePoint の知識がなくても取っつきやすいですし、jQuery との相性もよく、また単体テストもしやすいと思います。

*1:念のためお断りとして言っておくと、基本的にはプロバイダー ホスト型アドインを作って Azure なんかに置くのが推奨された手段です。ういうことができない特殊な事情がある場合の手段だと思ってくださいね!

ASP.NET MVC の Repository パターン再考

ASP.NET MVC が出始めた頃、Repository パターンが話題になりました。その後、あまり Repository パターンについての議論がされていないようなので、改めて Repository パターンについて考えてみたいと思います。

Repository パターンの基本

Repository パターンの目的はビジネス ロジックとデータ アクセス ロジックを分離することです。具体的には以下のようなコードになります。

まずはデータ アクセス ロジックを抽象化するためのインターフェイスを定義します。

public interface IRepository<T> {

    T GetOne(int id);

    IEnumerable<T> GetAll();

    void Add(T item);

    void Update(T item);

    void Delete(T item);

}

IRepository インターフェイスを実装し Entity Framework にアクセスするクラスを作成します。

public class PersonRepository : IRepository<Person> {

    private DbContext dbContext;

    public PersonRepository() {
        this.dbContext = new SampleDbContext();
    }

    public Person GetOne(int id) {
        return this.dbContext.Set<Person>().Find(id);
    }

    public IEnumerable<Person> GetAll() {
        return this.dbContext.Set<Person>().AsEnumerable();
    }

    public void Add(Person item) {
        this.dbContext.Set<Person>.Add(item);
        this.dbContext.SubmitChanges();
    }

    public void Update(Person item) {
        this.dbContext.SubmitChanges();
    }

    public void Delete(Person item) {
        this.dbContext.Set<Person>.Remove(item);
        this.dbContext.SubmitChanges();
    }

}

Repository を操作するビジネス ロジックを作成します。

public class SalaryService {

    private IRepository<Person> personRepository;

    public class SomeService() {
        this.personRepository = new PersonRepository();
    }

    public class SomeService(IRepository<Person> personRepository) {
        this.personRepository = personRepository;
    }

    public IEnumerable<Salary> GetSalaries() {
        // 何かしらのビジネス ロジック
    }

}

このクラスでは、抽象化された IRepository インターフェイスでアクセスするため、モックをインジェクションすることで、データベースに依存しない単体テスト ロジックを実施することができます。

[TestClass()]
public class SalaryServiceTest {

    [TestMethod()]
    public void GetSalariesTest() {
        var data = new [] { new Person() };
        var mock = new Mock<IRepository<Person>>();
        mock.Setup(repos => repos.GetAll())
            .Returns(data);
        var target = new SalaryService(mock.Object);
        var actual = target.GetSalaries();
    }

}

IRepository インターフェイスの必要性

そもそもとして「IRepository インターフェイスは必要か?」という話があります。データ アクセス ロジックをモックしたい、つまり単体テストからデータベースを分離したい理由としては、以下の理由が挙げられると思います。

  • 接続文字列に固有の情報が含まれるため、ビルド サーバーに持っていったときに自動テストが実施できない
  • 連続実行した場合など、データベースのデータによって、テスト結果が左右される可能性がある

SQL Server LocalDB を使えば、単体の mdf ファイルとして動作するので、環境による依存性を考慮する必要がありません。また CI サービスである AppVeyor でもサポートしていますので、ビルドサーバーでも自動テストを通すことができます。

<connectionStrings>
    <add name="DefaultConnection"
         connectionString="Data Source=(localdb)\mssqllocaldb; Initial Catalog=sampledb; AttachDbFilename=|DataDirectory|\sampledb.mdf; Integrated Security=True;"
         providerName="System.Data.SqlClient" />
</connectionStrings>
[ClassInitialize()]
public static void ClassInitialize(TestContext testContext) {
    AppDomain.CurrentDomain.SetData("DataDirectory", testContext.TestDeploymentDir);
}

App.config に DataDirectory を指定し、テスト クラスで単体テストの TestDeploymentDir に書き換えてあげれば、単体テストの実施ごとに異なる mdf が使われます。あとは Code First であれば Database.Create() するだけです。わざわざモックを使わなくても、ほとんどの場合は、こちらのほうが簡単に単体テストを実施できるはずです。

どうしてもデータベースを使いたくない場合でも、Entity Framework の DbContext クラスをモックしてしまうという方法があります。というのも、DbContext クラスはすでに Repository パターンを実装しているからです。

DbContext クラス (System.Data.Entity)

DbContext インスタンスは、データベースに照会してすべての変更をグループ化し、1 つの単位としてストアに書き戻すことができるような、作業単位パターンとリポジトリ パターンの組み合わせを表します。

DbContext クラスと DbSet クラスをモックすればデータベースにアクセスしない単体テストを実施することができます。

[TestClass()]
public class SalaryServiceTest {

    [TestMethod()]
    public void GetSalariesTest() {
        var mockData = new List<Person>() { new Person() };
        var mockDbSet = new Mock<DbSet<Person>>();
        mockDbSet.As<IEnumerable<Person>>()
            .Setup(dbSet => dbSet.GetEnumerator())
            .Returns(mockData.GetEnumerator());
        var mockDbContext = new Mock<DbContext>();
        mockDbContext.Setup(dbContext => dbContext.Set<Person>())
            .Returns(mockDbSet);
        var target = new SalaryService(mockDbContext.Object);
        var actual = target.GetSalaries();
    }

}

それでも IRepository インターフェイスを使う理由

データ アクセス ロジックが必ずしもデータベースであるとは限りません。REST や OData な Web API の可能性もあります。特定のデータ ストアに限定されない場合、IRepository インターフェイスでデータ アクセスを抽象化しておくと、ビジネス ロジックからはデータ ストアの種類を意識する必要なくアクセスができます。

public class OfferRepository : IRepository<Offer> {

    private const string BaseUrl = "http://example.com/Offers";

    public OfferRepository() { }

    public Offer GetOne(int id) {
        var webRequest = WebRequest.Create(BaseUrl + "/" + item.Id);
        webRequest.Method = "GET";
        var webResponse = webRequest.GetResponse();
        using (var stream = webResponse.GetResponseStream()) {
            var serializer = new JsonSerializer();
            using (var reader = new JsonTextReader(new StreamReader(stream))) {
                return serializer.Deserialize<Offer>(stream);
            }
        }
    }

    public IEnumerable<Offer> GetAll() {
        var webRequest = WebRequest.Create(BaseUrl);
        webRequest.Method = "GET";
        var webResponse = webRequest.GetResponse();
        using (var stream = webResponse.GetResponseStream()) {
            var serializer = new JsonSerializer();
            using (var reader = new JsonTextReader(new StreamReader(stream))) {
                return serializer.Deserialize<IEmumerable<Offer>>(stream);
            }
        }
    }

    public void Add(Offer item) {
        var webRequest = WebRequest.Create(BaseUrl);
        webRequest.Method = "POST";
        webRequest.ContentType = "application/json";
        using (var stream = webRequest.GetRequestStream()) {
            var serializer = new JsonSerializer();
            using (var writer = new JsonTextWriter(new StreamWriter(stream))) {
                serializer.Serialize(writer, item);
            }
        }
        webRequest.GetResponse();
    }

    public void Update(Offer item) {
        var webRequest = WebRequest.Create(BaseUrl + "/" + item.Id);
        webRequest.Method = "PUT";
        webRequest.ContentType = "application/json";
        using (var stream = webRequest.GetRequestStream()) {
            var serializer = new JsonSerializer();
            using (var writer = new JsonTextWriter(new StreamWriter(stream))) {
                serializer.Serialize(writer, item);
            }
        }
        webRequest.GetResponse();
    }

    public void Delete(Offer item) {
        var webRequest = WebRequest.Create(BaseUrl + "/" + item.Id);
        webRequest.Method = "DELETE";
        webRequest.GetResponse();
    }

}

Repository パターンを適切に実装するために

汎用の IRepository インターフェイス実装では、クエリ処理が実装されていないため、ビジネス ロジックでクエリを書いてしまいがちです。しかし、これは間違いです。

public class SalaryService {

    private IRepository<Person> personRepository;

    public class SomeService() {
        this.personRepository = new PersonRepository();
    }

    public class SomeService(IRepository<Person> personRepository) {
        this.personRepository = personRepository;
    }

    public decimal GetSalary(string name) {
        // この例のようにビジネス ロジックでクエリを書いたら駄目
        return this.personRepository.GetAll()
            .Where(person => person.Name == name)
            .Select(person => person.Salary)
            .Single();
    }

}

GetAll メソッドは IEnumerable を返しますので、Entity Framework では意図しない SQL が流れてしまう結果になります。

itkaeru.blogspot.jp

かといって、GetAll メソッドを IQueryable にしてしまうと、今度はせっかく分離したデータベースとの依存性が復活してしまいます。
できるだけクエリ処理は Repository の中に閉じ込めておき、汎用の IRepository で定義されているメソッドのうち、必要ないものは実装しないようにする必要があります。

public interface IPersonRepository {

    decimal GetSalary(string name);

}

public class PersonRepository : IRepository<Person>, IPersonRepository {

    private DbContext dbContext;

    public PersonRepository() {
        this.dbContext = new SampleDbContext();
    }

    Person IRepository<Person>.GetOne(int id) {
        throw new NotSupportedException();
    }

    IEnumerable<Person> IRepository<Person>.GetAll() {
        throw new NotSupportedException();
    }

    void IRepository<Person>.Add(Person item) {
        throw new NotSupportedException();
    }

    void IRepository<Person>.Update(Person item) {
        throw new NotSupportedException();
    }

    void IRepository<Person>.Delete(Person item) {
        throw new NotSupportedException();
    }

    public decimal GetSalary(string name) {
        // クエリ処理
        return this.dbContext.Set<Person>()
            .Where(person => person.Name == name)
            .Select(person => person.Salary)
            .Single();
    }

}

SharePoint 2013 のお知らせリストで「続きを読む」を実装する

「こういうの欲しいよね?」ということでサクッと JSLink を使って実装してしまいます。

f:id:karamem0:20161016214958p:plain

(function() {
    var overrideCtx = {};
    overrideCtx.ListTemplateType = 104;
    overrideCtx.BaseID = 100;
    overrideCtx.Templates = {};
    overrideCtx.Templates.Fields = {
        'Body': { 'View': function (ctx) {
            var id = ctx.CurrentItem.ID;
            var body = ctx.CurrentItem.Body.replace(/<("[^"]*"|'[^']*'|[^'">])*>/g,'').slice(0, 20);
            return '<div>' + body + '... <a href="' + ctx.displayFormUrl + '&ID=' + id + '">more</a></div>';
        }}
    };
    SPClientTemplates.TemplateManager.RegisterTemplateOverrides(overrideCtx);
})();

お知らせリストの本文列は拡張テキストなので内部的には HTML です。そこでまずは正規表現を使って HTML タグを取り除きます。その後、文字列を適当な長さに切り詰めます。今回は 20 文字にしていますが、そこは状況に応じて相談で。

Azure の仮想マシン (ARM) のネットワーク インターフェイスを変更する

Azure で仮想マシン (ARM) をポータルから作成するともれなくネットワーク インターフェイスが付いてくるのですが、面倒なことに作成済みのネットワーク インターフェイスに変更できないんですよね。そんなわけで PowerShell で変更してみます。

$vmName = "<仮想マシンの名前>"
$resourceGroupName = "<リソースグループの名前>"
$nicName = "<ネットワークインターフェイスの名前>"

$vm = Get-AzureRmVM -Name $vmName -ResourceGroupName $resourceGroupName
$nic = Get-AzureRmNetworkInterface -Name $nicName -ResourceGroupName $resourceGroupName

$vm.NetworkProfile.NetworkInterfaces[0].Id = $nic.Id

Update-AzureRmVM -VM $vm

ちなみに、ネットワーク インターフェイスを追加するには Add-AzureRmVMNetworkInterface を使えばいいようです。

blogs.technet.microsoft.com

SharePoint ファーム ソリューション ファイルを一発で展開する

開発時によく使うけど忘れるので書きました。
ソリューションをアンインストールした直後は削除できないのでループで回すようにしています。

github.com

Add-PSSnapin "Microsoft.SharePoint.PowerShell"

$path = "<ソリューションファイルのパス>"
$name = [System.IO.Path]::GetFileName($path)

$solution = Get-SPSolution -Identity $name -ErrorAction SilentryContinue
if ($solution -ne $null) {
    if ($solution.Deployed -eq $true) {
        Uninstall-SPSolution -Identity $name -Confirm:$false
    }
    while ($true) {
        try {
            Remove-SPSolution -Identity $name -Confirm:$false -ErrorAction Stop
            break
        } catch {
            Start-Sleep -Seconds 1
        }
    }
}
Add-SPSolution -LiteralPath $path
Install-SPSolution -Identity $name -GACDeployment