いよいよマスタ保守画面を追加していきます。DBアクセスにはDapperを使用します。

F#にはType Providerというものがあって、コンパイル時に実際のデータへアクセスしてモデルを構築してしまうという強力な仕組みがあります。
CSVファイル、JSON、XML、HTMLならFSharp.Data、データベースならSQL Providerというものがあります。

本来ならこれを使用するのが筋だと思うのですが、SQL Providerは.NET Coreでもビルドに .NET Frameworkが必要となっています。
F#のDBアクセスでは、他にRezoom.SQLという面白いものもあるのですが、こちらも.NET Frameworkで止まってます。Issueに、full support for type providers in .NET core.を待っているという言葉があります。
どうも、.NET Coreのリフレクション関連で.NET Frameworkから移行しきれていない機能があるように読めるのですが、よくわかりません...。

Type Providerについてはまたいずれどこかで探ってみるとして、今回はDapperで行きます。

Dapperは.NETでは がつくほど有名なMicro ORMなので、よく知らないという方は"Dapper"でググってみるとGoogle先生がこれでもか!と言うほどリストアップしてくれます。

今回追加する画面の仕様

今回追加する画面の仕様は以下の通りです。

  • メニューを選択するとDBへアクセスし、一覧画面を表示する
  • Typeという項目でフィルタができる
  • 新規データを登録できる
  • 一覧からデータを選択して編集、保存できる
  • 一覧からデータを選択して削除できる
  • データベースはSQLiteを使用
  • サーバー起動時に初期データの投入を行う
  • データベースへの接続文字列はappsettings.jsonから取得する

本ブログのデータベースはPostgresなのですが、今回は手軽にSQLiteで実装します。
データベースへの接続文字列はappsettings.jsonから取得するようにするので、SQLite以外のデータベースを使いたい方は対応するADO.NETプロバイダを導入すれば行けると思います。ADO.NETプロバイダさえ準備できれば、割と簡単に接続できてしまうのがDapperの良いところですね。

Taxonomyマスタは以下のようなモデルになります。レコードで実装します。

/// <summary>
/// TaxonomyTypeの列挙型
/// </summary>
type TaxonomyTypeEnum =
    | Category = 0
    | Tag = 1
    | Series = 2


/// <summary>
/// 投稿記事の分類情報を表します。
/// </summary>
type Taxonomy = {
    /// <summary>
    /// id
    /// </summary>
    Id : int64;

    /// <summary>
    /// 分類タイプ
    /// </summary>
    Type : TaxonomyTypeEnum;

    /// <summary>
    /// 名称
    /// </summary>
    Name : string;

    /// <summary>
    /// カテゴリのアドレスを定義するために使用されるurlスラッグ。
    /// </summary>
    UrlSlug : string;

    /// <summary>
    /// 説明
    /// </summary>
    Description : string option;
}

本ブログはこのテーブルで記事のカテゴライズをしています。
SAFEテンプレート上ではClient側とServer側の両方で使用しますので、Sharedフォルダに配置しています。

TaxonomyテーブルのSQLiteのcreate文は以下です。Idカラムは自動採番されます。

create table if not exists [Taxonomy] (
    [Id] integer primary key autoincrement,
    [Type] integer not null,
    [Name] text not null,
    [UrlSlug] text not null,
    [Description] text null
)

DapperとSystem.Data.Sqliteの導入

DapperとSqliteデータベースプロバイダの追加は以下のコマンドをプロジェクトルートディレクトリで実行します。

.paket\paket.exe add Dapper --group Server --project Server
.paket\paket.exe add System.Data.Sqlite --group Server --project Server

前回の記事で paket.referencesに自動で追加されない!と書いたのですが、--project プロジェクト名を付けることで出来ました!
前回の記事も修正しなきゃ...。

DataAccess.fs

F#でDapperを使うためのラッパー関数をDataAccess.fsというモジュールに定義しています。

module DataAccess

open System
open System.Data
open System.Collections.Generic
open System.Dynamic
open Dapper

// https://stackoverflow.com/questions/42797288/dapper-column-to-f-option-property
type OptionHandler<'T> () =
    inherit SqlMapper.TypeHandler<option<'T>> ()

    override __.SetValue (param, value) =
        let valueOrNull =
            match value with
            | Some x -> box x
            | None   -> null

        param.Value <- valueOrNull

    override __.Parse value =
        if isNull value || value = box DBNull.Value
        then None
        else Some (value :?> 'T)

let addOptionHandlers() =
    // 他にも使用する型があるなら追加する
    SqlMapper.AddTypeHandler(OptionHandler<string>())
    SqlMapper.AddTypeHandler(OptionHandler<int>())
    SqlMapper.AddTypeHandler(OptionHandler<DateTime>())
    SqlMapper.AddTypeHandler(OptionHandler<decimal>())


let private logError work = 
    try
        work()
    with 
    | ex ->
        let name = ex.GetType().Name
        printfn "%s:%s" name ex.Message
        reraise()

// 参考 https://gist.github.com/vbfox/1e9f42f6dcdd9efd6660
let query<'Result> (sql:string) (connection:IDbConnection) : 'Result seq =
    let work() = 
        connection.Query<'Result>(sql)
    work |> logError

let parametrizedQuery<'Result> (sql:string) (param:obj) (connection:IDbConnection) : 'Result seq =
    let work() = 
        connection.Query<'Result>(sql, param)
    work |> logError
   
let mapParametrizedQuery<'Result> (sql:string) (param : Map<string,_>) (connection:IDbConnection) : 'Result seq =
    let expando = ExpandoObject()
    let expandoDictionary = expando :> IDictionary<string,obj>
    for paramValue in param do
        expandoDictionary.Add(paramValue.Key, paramValue.Value :> obj)

    connection |> parametrizedQuery sql expando

let execute (sql:string) (param:_) (connection:IDbConnection) =
    let work() = 
        let response = connection.Execute(sql, param)
        printfn "response:%d" response
        response
    work |> logError

Dapperは System.Data.IDbConnectionに対する拡張メソッドとして、QueryExecuteが定義されているだけというとてもきれいなAPIになっています。
引数として、SQL文とSQLパラメータを受ける形式になっていて、SQLパラメータはobject型の任意のオブジェクトを受け取ります。
与えられたSQLパラメータオブジェクトのプロパティがSQL文中のパラメータプレースホルダにマッピングされて実行されます。

DataAccess.fsは、ここのコードをベースにしています。
違いはエラーが起きた場合の対処ぐらいです。
mapParametetrizedQueryはF#の連想配列を受ける関数で、内部はExpandoObjectに変換してからDapperを使用しています。
Dapperはobject型のプロパティの他、IDictionaryのKeyもマッピングしてくれます。
今回はパラメータがある場合レコード型を定義して、それをparametrizedQueryに渡して呼び出しています。

パラメータの数を状況によって変動させたいような場合はmapParametetrizedQueryを使用すると良いのではないでしょうか。
これのExecute版は定義していませんが、Executeを使うのはデータ更新の場合であることが多く、パラメータにはモデルそのものを渡すことになるので省略しています。
参考にした先ほどのリンクもそういうことなんだろうなぁと思っています。

F#にはデータが無いという状況を表す Option<'T> という判別共用体がありますが、DataAccess.fsではnullをNoneに変換するための型変換を行なうようにしています。
StackOverFlowのここの記事を参考にしました。
Dapper使用前のどこかで一回、addOptionHandlers()を呼び出すと、null可の項目にnullが入っていた場合、Noneに変換されるようになります。
今回はTaxonomyレコード型のDescriptionを string optionOption<string> と同義) で定義しています。

DbInit.fs (初期データの投入)

DbInit.fsモジュールにて、初期データの投入を行っています。
接続文字列をappsettings.jsonから取得するために、Saturnのapp_configに初期データ投入処理の関数を登録しています。

src\Server\Server.fs

  :
  :
  :
let app = application {
    url ("http://0.0.0.0:" + port.ToString() + "/")
    use_router webApp
    memory_cache
    use_static publicPath
    use_gzip
    app_config DbInit.Initialize
}

run app

app_configに指定できる関数は IApplicationBuilder を引数として受けて IApplicationBuilder を返す関数(F#ではIApplicationBuilder -> IApplicationBuilder と表現します)を指定できます。
このIApplicationBuilderのApplicationServicesプロパティに設定されている IServiceProvider のGetServiceメソッドを使用して IConfiguration オブジェクトをDIコンテナから取得することで設定情報にアクセスすることが出来ます。

src\Server\DbInit.fs

  :
  :
  :
let Initialize (app:IApplicationBuilder) =
    let config = app.ApplicationServices.GetService<IConfiguration>()
    let connectionString = config.GetConnectionString("BlogDb")
    let conn = getConnection connectionString

    // テーブル作成
    conn |> createTaxonomyTable

    match (conn |> existsTaxonomies) with 
    | 0 -> 
        // データ追加
        let records = [
            {Id=0L; Type=TaxonomyTypeEnum.Category; Name=".NET"; UrlSlug="dotnet"; Description=Some ".NET Framework, .NET Core に関する話題が中心です。"}
            {Id=0L; Type=TaxonomyTypeEnum.Category; Name="猫"; UrlSlug="cats"; Description=Some "飼っている2匹の猫の話題が中心です。"}
            {Id=0L; Type=TaxonomyTypeEnum.Tag; Name="ASP.NET Core"; UrlSlug="asp-net-core"; Description=Some "ASP.NET Coreに関する話題です。"}
            {Id=0L; Type=TaxonomyTypeEnum.Tag; Name="nekoni.net"; UrlSlug="create-nekoni-net"; Description=Some "本サイトの開発に関する話題です。"}
            {Id=0L; Type=TaxonomyTypeEnum.Tag; Name="マロ"; UrlSlug="maro"; Description=Some "うちの営業部長。先住猫のマロに関しての話題です。"}
            {Id=0L; Type=TaxonomyTypeEnum.Tag; Name="フク"; UrlSlug="fuku"; Description=Some "しんねりさん。2匹目の猫、フクちゃんに関しての話題です。"}
        ]
        conn |> addTaxonomies records
        app    
    | _ -> app

appsettings.jsonには次のように接続文字列が定義されています。

{
  "Logging": {
    "IncludeScopes": false,
    "LogLevel": {
      "Default": "Warning"
    }
  },
  "ConnectionStrings": {
    "BlogDb": "Data Source=BlogData.db"
  }
}

let connectionString = config.GetConnectionString("BlogDb")の部分でConnectionStringsのBlogDbというキーで参照しています。

Saturnも通常のASP.NET Coreと同様、内部でWebHost.CreateDefaultBuilderを使用してIWebHostBuilderを初期化しているため、appsettings.jsonの内容を読み込むことが出来ます。

Visual Studio 2017で ASP.NET Core WebアプリケーションをF#で作成してみるとわかりますが、F#で作成したStartUpクラスにもちゃんとDIしてくれるんですよね。

Saturnの場合、StartUpクラスのConfigureメソッドと同様の指定がapp_configを使用すれば出来るようです。
指定するのは関数でクラスでは無いのでDIはしてもらえません(ASP.NET CoreのDIはコンストラクタインジェクションのみ)が、上記のようにGetService<'T>メソッドを使用すれば取得出来るというのをここで見つけました。
ありがとう、Samuele Rescaさん&Google先生。

サーバー側API

Fable.Remotingでクライアントとサーバーのやりとりを実装します。
SAFEテンプレートでcounterサンプルのAPIが定義済みですが、今回追加する画面のAPIと合わせてServicesというフォルダに各画面毎にAPI定義を実装します。

SAFEテンプレートではcounterサンプルのAPIはServer.fsに記載されていますが、これをServer\Services\Counter.fsに移動します。

src\Server\Services\Counter.fs

module Services.Counter

open System.Threading.Tasks

open Microsoft.AspNetCore.Http
open FSharp.Control.Tasks.V2
open Giraffe
open Fable.Remoting.Server
open Fable.Remoting.Giraffe

open Shared

let getInitCounter () : Task<Counter> = task { return 42 }
let apiRoute:(HttpFunc -> HttpContext -> HttpFuncResult)  = 
    let api:ICounterApi = { 
        initialCounter = getInitCounter >> Async.AwaitTask
    }
    Remoting.createApi()
    |> Remoting.withRouteBuilder Route.builder
    |> Remoting.fromValue api
    |> Remoting.buildHttpHandler

apiRouteの定義がFable.Remotingのサーバー側定義となります。
initialCounterという名前で公開していて、実装はgetInitCounter関数を呼び出すというようになっています。
withRouteBuilder関数に指定している Route.builderはShared.fsに定義しているURLを作成する関数です。

今回追加するページ(Taxonomies)のAPI

同様に、今回追加するページ Taxonomies のAPI定義は以下となります。

src\Server\Services\Taxonomies.fs

module Services.Taxonomies

open System.Threading.Tasks

open Microsoft.AspNetCore.Http
open Microsoft.Extensions.DependencyInjection
open FSharp.Control.Tasks.V2
open Giraffe
open Fable.Remoting.Server
open Fable.Remoting.Giraffe

open Shared
open Microsoft.Extensions.Configuration

let getTaxonomies (connectionString:string) (param: GetTaxonomiesParam) :Task<GetTaxonomiesResult> = task {
    return Repository.getTaxonomies connectionString param.taxonomyType param.pagenation
}

let getTaxonomy (connectionString:string) (id:int) = task {
    return Repository.getTaxonomy connectionString id
}

let addNewTaxonomy (connectionString:string) (record:BlogModels.Taxonomy) = task {
    return Repository.addNewTaxonomy connectionString record
}

let updateTaxonomy (connectionString:string) (record:BlogModels.Taxonomy) = task {
    return Repository.updateTaxonomy connectionString record
}

let removeTaxonomy (connectionString:string) (record:BlogModels.Taxonomy) = task {
    return Repository.removeTaxonomy connectionString record
}

let apiRoute:(HttpFunc -> HttpContext -> HttpFuncResult) =
    let getApi (ctx:HttpContext) :ITaxonomyApi = 
        // let config = ctx.RequestServices.GetService(typeof<IConfiguration>) :?> IConfiguration
        let config = ctx.RequestServices.GetService<IConfiguration>();
        let connectionString = config.GetConnectionString("BlogDb")
        printfn "ConnectionString:%s" connectionString
        
        { 
            getTaxonomies = getTaxonomies connectionString >> Async.AwaitTask
            getTaxonomy = getTaxonomy connectionString >> Async.AwaitTask
            addNewTaxonomy = addNewTaxonomy connectionString >> Async.AwaitTask
            updateTaxonomy = updateTaxonomy connectionString >> Async.AwaitTask
            removeTaxonomy = removeTaxonomy connectionString >> Async.AwaitTask
        }

    Remoting.createApi()
    |> Remoting.withRouteBuilder Route.builder
    |> Remoting.fromContext (fun ctx -> getApi ctx)
    |> Remoting.buildHttpHandler

基本的な構造はServer\Services\Counter.fsと同様ですが、注目して欲しいのは、Remoting.createApi()に続くパイプラインの2行目です。

Counterでは

    |> Remoting.fromValue api

となっていたところが、Taxonomiesでは

    |> Remoting.fromContext (fun ctx -> getApi ctx)

となっています。
Remoting.fromValueではAPI定義そのものを指定していましたが、Remoting.fromContextHttpContextを引数に取る関数を指定します。
TaxonomiesではDBアクセスに接続文字列が必要となるので、HttpContextRequestServicesプロパティに設定されているIServiceProviderGetServiceメソッドを使って設定情報を取得しています。DbInit.fsでやった方法と同じですね。
ちなみにコメントアウトされている行の書き方でも取得できますが、型パラメータを指定した下の書き方の方が短くていいですよね。
下の書き方をする場合は、open Microsoft.Extensions.DependencyInjectionをお忘れなく。

TaxonomiesのAPIは一覧取得、idによる一件取得、新規追加、更新、削除の5つのメソッドから成っています。
実際のDBアクセスはRepository.fsというモジュールにまとめてあります。

Repository.fs

module Repository

open System.Data.SQLite
open DataAccess
open Shared
open Shared.BlogModels
open Dapper

module SqliteTypeHandler =
    open Dapper
    type TaxonomyTypeEnumHandler () =
        inherit SqlMapper.TypeHandler<TaxonomyTypeEnum> ()

        override __.SetValue (param, value) =
            param.Value <- value

        override __.Parse value =
            enum<TaxonomyTypeEnum> (value :?> int)

    let addTypeHandlers () =
        SqlMapper.AddTypeHandler(TaxonomyTypeEnumHandler())

let getConnection (connectionString:string) = 
    new SQLiteConnection(connectionString)


let getTaxonomies (connectionString:string) (taxonomyType:TaxonomyTypeEnum option) (page:PagerModel)  =
    let connection = getConnection connectionString

    let sqlWhere = 
        match taxonomyType with
        | None -> ""
        | Some x -> sprintf "where [Type] = %d " (int x)

    let getCount criteria =
        let sql = 
            """
            select count(1) as [cnt]
            from [Taxonomy]
            """
        connection 
        |> query<int64> (sql + criteria) |> Seq.head
    let newPager = {page with allRowsCount = getCount sqlWhere }
    let newCurrent = min newPager.currentPage newPager.LastPage

    let getList criteria =    
        let sql = 
            """
            select *
            from [Taxonomy]
            """
        let sqlOrder = "order by [Id] "
        let sqlLimitAndOffset = sprintf "limit %d offset %d" page.rowsPerPage ((newCurrent - 1L) * page.rowsPerPage)
        connection 
        |> query<Taxonomy> (sql + criteria + sqlOrder + sqlLimitAndOffset)

    { data = getList sqlWhere
      pagenation = {newPager with currentPage = newCurrent} }


type IdParam = { 
    Id : int64;
}
let getTaxonomy (connectionString:string) (id:int64) =
    let connection = getConnection connectionString

    let sql =
        """
        select * from [Taxonomy]
        where [Id] = @Id
        """
    let param = {Id = id}

    connection 
    |> parametrizedQuery<Taxonomy> sql param
    |> Seq.tryHead


let addNewTaxonomy (connectionString:string) (record:Taxonomy) =
    let connection = getConnection connectionString

    let sql = 
        """
        insert into [Taxonomy] (
          [Type] 
         ,[Name]
         ,[UrlSlug]
         ,[Description]
        )
        values (
          @Type 
         ,@Name 
         ,@UrlSlug 
         ,@Description
        )
        """
    connection 
    |> execute sql record

let updateTaxonomy (connectionString:string) (record:Taxonomy) =
    let connection = getConnection connectionString

    let sql = 
        """
        update [Taxonomy] 
        set 
          [Type] = @Type
         ,[Name] = @Name
         ,[UrlSlug] = @UrlSlug
         ,[Description] = @Description
        where [Id] = @Id
        """
    connection 
    |> execute sql record

let removeTaxonomy (connectionString:string) (record:Taxonomy) =
    let connection = getConnection connectionString

    let sql =
        """
        delete from [Taxonomy]
        where [Id] = @Id
        """
    connection 
    |> execute sql record        

一覧取得のgetTaxonomiesだけ、ページング機構を実現するためちょっと複雑になっていますが、他はDataAccess.fsにSQLを投げてるだけの簡単な処理になっていると思います。

最初の方のサブモジュールSqliteTypeHandlerはちょっと特殊な事情なのですが、SqliteのINTEGER型はSystem.Int64になって返ってくることへの対策です。
Taxonomyのモデルを見ていただきたいのですが、Type という項目はTaxonomyTypeEnumという列挙型になっています。

/// <summary>
/// TaxonomyTypeの列挙型
/// </summary>
type TaxonomyTypeEnum =
    | Category = 0
    | Tag = 1
    | Series = 2

本来、Dapperは列挙型で定義した項目にもきれいにマッピングしてくれるのですが、この列挙型はSystem.Int32のためマッピングでエラー(InvalidCastException)になってしまいます。
で、次のようにInt64の列挙型にすればいいのかなと安易に思っていたのですが、

/// <summary>
/// TaxonomyTypeの列挙型
/// </summary>
type TaxonomyTypeEnum =
    | Category = 0L
    | Tag = 1L
    | Series = 2L

なーんと、FableがInt64の列挙型に対応していないんですよねぇ...。TaxonomyモデルはClient側でも使用するのでこれは困りました。
まぁ、実際はこの3値しか取りえないのでInt64である必要も無いのですが...。
SqliteでInt32の型を指定できればいいのですが、それも無理そうなので型変換で対処しました。他のDBならこれは不要かと思います。

Server.fs

// Dapperの初期化。null←→option の変換設定
DataAccess.addOptionHandlers()
// Sqliteの型変換設定
Repository.SqliteTypeHandler.addTypeHandlers()

let publicPath = Path.GetFullPath "../Client/public"
let port = 8085us

let webApp = router {
    forward "/api/ICounterApi" Services.Counter.apiRoute
    forward "/api/ITaxonomyApi" Services.Taxonomies.apiRoute
}

let app = application {
    url ("http://0.0.0.0:" + port.ToString() + "/")
    use_router webApp
    memory_cache
    use_static publicPath
    use_gzip
    app_config DbInit.Initialize
}

run app

頭のopenのあたりは省略しています。

まず、DapperにTypeHandlerの登録を行っています。TypeHandlerの登録はどこか一カ所で行えばOKなのでこのモジュールに書いてあります。

それからルートの定義です。
SAFEテンプレートではこのモジュールに直接Fable.Remotingの定義がしてあり、それをuse_routerに指定してありました。
今回、Fable.RemotingのAPI定義はServicesディレクトリ以下に移動したので、ここでルートを定義してuse_routerに指定という形にしてあります。
見たまんまの感じで、"/api/ICounterApi"という要求が来たら、Fable.Remotingで定義したルートへ流すという感じになります。


うー。Client側の説明もしたかったのですが、ここまででだいぶ長くなってしまったので、次回にしたいと思います。

次回説明分も含め、コードはGitHubのリポジトリをご覧ください。