вторник, 3 июля 2018 г.

ASP.Net MVC + EF Core: пробуем подружить с F#.

Введение

В процессе описания мы создадим проект ASP.Net MVC на платформе .Net Core на языке F#, добавим к нему Entity Framawork Core и разберемся как все это можно совместить. Для того чтобы не "изобретать велосипед", я взял за основу руководство, написанное для языка C# и далее буду действовать в соответствии с ним, но выполнять буду на F#, подробно описывая "трудности перевода", с которыми придется столкнуться и способы их преодоления.
Я не ставлю перед собой задачу описать все подробности процесса, такие как установка необходимого ПО, такого как .Net Core, SQL Server или Visual Studio Code, поскольку это все несложно найти в сети, да и мне для этой статьи не понадобилось все это делать, поскольку у меня уже все было установлено, что-то с Visual Studio, а VS Code просто раньше установил. Основная задача этого повествования - описать работу этого всего с языком F#, а все остальное здесь будет упоминаться по мере надобности.
Сразу хочу сказать, что делать все описанное здесь в Visual Studio не получится и даже открывать вновь созданный проект в ней - тоже не рекомендуется. Причина в том, что студия вносит в проект свои правки, а поскольку она не знает, как работать с такими типами проектов, то эти правки оказываются " мимо тазика". Например, она впихивает туда анализаторы кода, имеющие несовместимую с платформой версию. Кроме того, студия вообще не воспринимает данный проект как проект F#, в котором важен порядок компиляции файлов, она это не поддерживает и редактировать в ней файл проекта в XML-редакторе - тоже затруднительно. Использовать ее, конечно, можно, но только как вспомогательный инструмент, например можно создать аналогичный проект на C#  и пользоваться браузером объектов. Кроме того, там есть такие инструменты, как Обозреватель Серверов и Обозреватель объектов SQL Server, в которых хоть и нет насущной необходимости, однако они могут облегчить работу с базой данных. Поэтому для основной работы мы будем использовать Visual Studio Code.

Создание проекта

Далее предполагается, что .Net Core на компьютере установлен, равно как и VS Code.
Создаем папку, пусть это будет папка решения, можно даже назвать ее Solution, хотя там решения как в VS не будет, но папка проекта будет внутри. Далее для создания проекта мы можем воспользоваться либо "командной строкой" (cmd.exe), либо терминалом PowerShell, либо встроенным терминалом VS Code, который можно открыть сочетанием клавиш Ctrl+`, или Ctrl, если смотреть в русской раскладке. Или можно открыть через меню Вид>>Интегрированный терминал.
Если работаем через VS Code, то надо открыть вновь созданную папку решения в этой среде, в остальных случаях надо перейти в эту папку с помощью команды cd, поскольку команда dotnet, с которой мы и будем иметь дело, работает с текущим каталогом.
В руководстве для создания проекта использовалась команда
dotnet new mvc
она создает новый проект по шаблону mvc в текущем каталоге на языке C#, но нам нужен такой же проект, в специально созданном подкаталоге текущего каталога и для языка F#. Первую задачу можно решить, создав папку вручную и перейдя туда с помощью той же команды cd. Но к счастью этого делать не придется, поскольку мы можем указать и язык, и каталог с помощью аргументов команды. Нам нужно создать проект под названием EFCoreWebDemo, стало быть команда будет выглядеть так.
dotnet new mvc -o EFCoreWebDemo -lang F#
Эта команда создаст папку EFCoreWebDemo в текущем каталоге(а это каталог решения) и в этой папке разместит все необходимые файлы проекта. Дальше из этого каталога открываем VS Code и начинаем работать с проектом.

Подготовка к использованию

Для того, чтобы VS Code лучше поддерживал язык F#, желательно установить расширение, которое называется Ionide-fsharp, это же рекомендуют сделать и в документации по языку. Сказать, что оно решает все проблемы – нельзя, но у этого расширения есть свой обозреватель решений, в котором можно добавлять файлы, перемещать их вверх-вниз и делать еще кучу актуальных для языка вещей. Кроме того, оно дает хорошие подсказки по коду и, например, над функциями пишет их сигнатуры, что актуально для этого языка. Работает это все, правда, медленно, так что лучше его использовать в сочетании с обычными функциями программы и я, в основном, буду писать именно о них.
Далее, если мы пройдемся по файлам проекта, то безусловно заметим, что представления у нас написаны с использованием C#-версии Razor, так что от этого языка нам в любом случае не отвертеться.
Далее в руководстве выполняется еще несколько команд, которые устанавливают инструменты Entity Framework, необходимые для  работы, но мы пойдем другим путем ©.
Во-первых, статья не новая и версии продуктов там указаны не самые последние и на это надо обратить внимание, поскольку те версии, которые использовал я – актуальны для момента написания этого текста, а далее все будет меняться и могут возникнуть проблемы совместимости, так что актуальные версии надо смотреть на официальных сайтах продуктов или изменять неудачные попытки установки в соответствии с рекомендациями сообщений об ошибках и предупреждений. Во-вторых, тот способ, который использую я, проще и дает возможность простой копипастой установить все в один присест.
Итак, нам нужно открыть в редакторе кода файл проекта, который у нас называется EFCoreWebDemo.fsproj. В этом файле надо найти секцию следующего вида
  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.App" />
  </ItemGroup>
И далее ее надо изменить, чтобы она выглядела вот так:
  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.App" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="2.1.1" />
    <DotNetCliToolReference Include="Microsoft.EntityFrameworkCore.Tools" Version="2.1.1" />
  </ItemGroup>
Следующее, на что надо обратить внимание – это еще одна секция ItemGroup, в которой содержится список файлов. Если бы это был проект C#, то такой секции мы скорей всего не обнаружили бы, там файлы, находящиеся в папках проекта, включаются автоматически, но здесь важен порядок следования файлов, поэтому даже если вновь добавленный файл и будет включен, то он просто может оказаться не там, где надо. Здесь критически важно, чтобы функция, помеченная как точка входа была расположена в самом конце самого последнего файла проекта, а это файл Program.fs, и он всегда должен находиться в конце списка. Другой момент: во вновь созданном проекте файлы модели находятся ниже файлов контроллеров, а это не есть хорошо, поскольку модель используется в контроллерах, но никак не наоборот.
В редакторе кода удобно менять расположение файлов, для этого надо навести каретку на нужную строку и, зажав Alt, перемещать строку стрелками на клавиатуре вверх или вниз. В расширении для работы с F# тоже можно, но там это все тормозит и вообще не очень удобно реализовано.
Теперь, чтобы все изменения вступили в силу, надо сохранить файл и построить проект  
dotnet build
(я напоминаю, что текущим каталогом командной строки должен быть каталог проекта). После построения (вместо которого можно запустить проект командой run или просто восстановить командой restore) надо проверить доступность команд ef,
dotnet ef -h

Создание модели

В папку Models добавим файл EFCoreWebDemoContext.fs. А в файл проекта надо добавить запись о новом файле, в ту самую группу, где все файлы. Запись будет такой
  <Compile Include="Models/EFCoreWebDemoContext.fs" />

Напомню, что модели мы размещаем выше контроллеров, так что на данный момент эта секция выглядит так:
  <ItemGroup>
    <Compile Include="Models/ErrorViewModel.fs" />
    <Compile Include="Models/EFCoreWebDemoContext.fs" />
    <Compile Include="Controllers/HomeController.fs" />
    <Compile Include="Startup.fs" />
    <Compile Include="Program.fs" />
  </ItemGroup>

Сам код класса выглядит следующим образом:
namespace EFCoreWebDemo
open Microsoft.EntityFrameworkCore
//open System.ComponentModel
type EFCoreWebDemoContext() =
    inherit DbContext()
    [<DefaultValue>]val mutable books : DbSet<Book>
    member this.Books with get() = this.books and set(v) = this.books <- v

    [<DefaultValue>]val mutable authors : DbSet<Author>
    member this.Authors with get() = this.authors and set(v) = this.authors <- v

    override this.OnConfiguring(optionsBuilder) =
        optionsBuilder.UseSqlServer("Data Source=(localdb)\Test;Database=FSMVCDB;Trusted_Connection=True;MultipleActiveResultSets=true") |> ignore

Здесь следует обратить внимание на несколько моментов:
1.      По сравнению с аналогичным классом из руководства я закомментировал открытие пространства имен System.ComponentModel. Во-первых, оно не используется; а во-вторых, оно содержит атрибут DefaultValue и это имя конфликтует с одноименным атрибутом, из пространства Microsoft.FSharp.Core, который далее используется в коде.
2.      Автосвойства из руководства я заменил свойствами с явной реализацией. Причина в том, что F# не имеет возможности создавать автосвойства без инициализации, как это имело место в руководстве, а если инициализировать значением null, то работать это не будет (проверено).
3.      Я внес изменения в строку подключения. Поскольку я создал экземпляр сервера с именем (localdb)/Test, то именно его я и указал в строке подключения. Точно такая же строка будет работать только в том случае, если сделать также.


 Теперь что касается собственно классов модели предметной области (в нашем случае это Book и Author), здесь есть не очень приятные новости. Как я уже упоминал выше, в коде F# видно только то, что в списке файлов находится выше этого кода. А классы модели обычно ссылаются друг на друга. Из-за этого нам придется все классы модели разместить в одном файле и использовать ключевое слово and. В версии 4.1 языка, появилась возможность создавать рекурсивные пространства имен, причем именно для таких задач, но, к сожалению, это тоже действует только в рамках одного файла и проблему не решает.
Таким образом, вместо добавления отдельного файла для каждого класса, мы добавим файл Model.fs в папку Models со следующим кодом:
namespace EFCoreWebDemo
open System.Collections.Generic
type Book() =
    member val BookId = 0 with get, set
    member val Title = "" with get, set
    member val AuthorId = 0 with get, set
    [<DefaultValue>]val mutable author : Author
    member this.Author with get() = this.author and set(v) = this.author <- v
and Author() =
    member val AuthorId = 0 with get, set
    member val FirstName = "" with get, set
    member val LastName = "" with get, set
    member val Books = new List<Book>() :> ICollection<Book> with get, set

Здесь также следует обратить внимание на то, что свойство Author класса Book пришлось реализовать явно, поскольку изначально я инициировал его вновь созданным объектом Author и в результате все книги, которые я добавлял, создавали новую запись в таблицу с авторами, у которой поля с именем и фамилией были пустыми. И не забываем добавить запись о новом файле в файл проекта, причем модель должна находиться выше чем EFCoreWebDemoContext, поскольку последний ссылается на классы модели.

Пробуем добавить миграцию

Теперь, когда у нас готова модель предметной области, мы вполне можем заняться генерацией схемы базы данных на ее основе. Для этого существует процесс миграции и выполнить его – проще пареной репы. Запускаем команду
dotnet ef  migrations add CreateDatabase
Ждем… и… получаем большой облом. EF внезапно сообщает нам, что для языка F# миграция не поддерживается и предлагает три варианта решения проблемы:
1.      За миграцию отвечает специальный интерфейс (IMigrationCodeGenerator вроде бы). Так вот нужно найти его готовую реализацию и подключить к проекту. Я не нашел, так что счел, что вариант – так себе.
2.      Реализовать интерфейс самому. Я разобрался что это за интерфейс и сколько потребуется работы, так что моя оценка этого варианта – чуть лучше, чем так себе.
3.      Создать проект на C#, добавить к нему ссылку на текущий проект и выполнить миграцию там. Вот этим мы сейчас и займемся.

Проект на C# и миграция


Собственно, шаги те же. Создадим в папке решения (вот зачем нужна была эта папка) еще один проект. Если делать это из интегрированного терминала VS Code, который у нас открыт в папке текущего проекта, то введем команду
dotnet new mvc -o ../migrations
Таким образом мы создадим в условной папке решения папку migrations и в ней проект mvc, только на языке C#. Теперь нам нужно внести в этот проект несколько изменений, для этого желательно открыть еще один экземпляр VS Code и в нем открыть папку нового проекта. Открываем файл migrations.cs и добавляем в него ссылку на наш основной проект, а также указать в нем понадобятся те же пакеты NuGet, которые мы добавили в основной проект. После этого файл проекта будет выглядеть так
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>netcoreapp2.1</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <ProjectReference Include="..\EFCoreWebDemo\EFCoreWebDemo.fsproj">
      <Name>EFCoreWebDemo.fsproj</Name>
    </ProjectReference>
  </ItemGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.App" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="2.1.1" />
    <DotNetCliToolReference Include="Microsoft.EntityFrameworkCore.Tools" Version="2.1.1" />
  </ItemGroup>
</Project>

Процесс миграции сводится к тому, что сначала осуществляется поиск классов, наследующих DbContext, в них перебираются свойства типа DbSet<TEntity> и по джинерик-параметрам этих свойств формируется структура базы данных, а точнее – код, который должен создать базу данных нужной структуры. На данный момент в нашем проекте, созданном специально для выполнения миграций нет ни одного класса, наследующего DbContext, и нам придется такой класс создать. Фактически этот класс будет дублировать класс EFCoreWebDemoContext из основного проекта. В руководстве, по которому я делаю этот проект уже есть готовый код этого класса на C#, но в реальных условиях придется дублировать этот класс либо вручную, либо написав какой-нибудь скрипт, который будет делать это автоматически. К счастью все классы модели дублировать не придется – только контекст. Итак, нам надо добавить в папку Models файл EFCoreWebDemoContext.cs следующего содержания (код взял из руководства, только в строке подключения изменили имя сервера).
using Microsoft.EntityFrameworkCore;

namespace EFCoreWebDemo
{
    public class EFCoreWebDemoContext : DbContext
    {
        public DbSet<Book> Books { get; set; }
        public DbSet<Author> Authors { get; set; }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder.UseSqlServer(@"Server=(localdb)\Test;Database=EFCoreWebDemo;Trusted_Connection=True;MultipleActiveResultSets=true");
        }
    }
}

После этого выполняем команду 
dotnet ef  migrations add CreateDatabase
и после ее выполнения в папке проекта появляется папка Migrations с файлами с кодом. Теперь можно обновить базу данных
dotnet ef database update

При этом надо иметь в виду, что для того, чтобы команда выполнилась сервер, указанный в строке подключения, должен существовать и быть запущенным.  Создать и запустить его можно разными способами: в Visual Studio с помощью обозревателя объектов SQL Server; можно установить SQL Server Management Studio; или, скажем, с помощью утилиты sqllocaldb, выполнив команду  
sqllocaldb create Test
Если используется файловая база данный, то файл уже должен существовать.
После того, как будет создана база данных, можно также поиграться с атрибутами, создать новую миграцию, как это описано в руководстве, здесь я не буду уделять этому внимание.

Экземпляр VS Code с проектом миграции теперь можно закрыть, мы к этому проекту обращаться больше не будем.

Работа с данными

Создаем в папке Controllers файл AuthorController.fs. Не забываем добавить упоминание о нем в файл проекта
    <Compile Include="Controllers/AuthorController.fs" />
Также не забываем о порядке следования файлов – контроллеры у нас ниже модели. Код этого файла будет следующим:
namespace EFCoreWebDemo.Controllers
open Microsoft.AspNetCore.Mvc
open Microsoft.EntityFrameworkCore
open EFCoreWebDemo
type AuthorController() =
    inherit Controller()

    member this.Index() =
        use context = new EFCoreWebDemoContext()
        let model = context.Authors.AsNoTracking().ToListAsync() |> Async.AwaitTask |> Async.RunSynchronously
        this.View(model)
   
    [<HttpGet>]
    member this.Create() =
        this.View()

    [<HttpPost>]
    member this.Create([<Bind("FirstName, LastName")>]author: Author) =
            async{
                use context = new EFCoreWebDemoContext()
                context.Add(author) |> ignore
                do! context.SaveChangesAsync() |> Async.AwaitTask |> Async.Ignore
                return this.RedirectToAction("Index")
            } |> Async.RunSynchronously


Здесь я не следовал в точности первоисточнику и малость поигрался. В руководстве первый и последний методы реализованы как асинхронные. Здесь я в первом случае просто дождался выполнения асинхронного метода, во втором – использовал асинхронный рабочий процесс, который в конце запустил синхронно. В другом контроллере в аналогичной ситуации я вызывал SaveChanges вместо его асинхронного аналога. Кроме того, методы контроллера могут возвращать помимо ViewResult также Task<ViewResult> и даже Async<ViewResult> и конвейер обработки ASP.Net MVC все правильно обрабатывает, так что в последнем методе можно убрать в конце |> Async.RunSynchronously и все равно все отработает правильно.

Далее в папке Views создаем папку Author, добавляем в нее файлы Index.cshtml и Create.cshtml и их содержимое вставляем из руководства не меняя.

Index.cshtml
@model IEnumerable<Author>
@{
    ViewBag.Title = "Authors";
}
<h1>@ViewBag.Title</h1>
<ul>
@foreach (var author in Model)
{
    <li>@author.FirstName @author.LastName</li>
}
</ul>

<div>@Html.ActionLink("New", "create")


Create.cshtml

@model Author
@{
    ViewBag.Title = "New Author";
}

<h1>@ViewBag.Title</h1>

@using(Html.BeginForm()){
  <div class="form-group">
    @Html.LabelFor(model => model.FirstName)
    @Html.TextBoxFor(model => model.FirstName, new { @class="form-control"})
  </div>
  <div class="form-group">
    @Html.LabelFor(model => model.LastName)
    @Html.TextBoxFor(model => model.LastName, new { @class="form-control"})
  </div>
  <button type="submit" class="btn btn-default">Submit</button>
}

На данном этапе мы уже можем запустить проект, выполнив команду
dotnet run
После выполнения команды нужно в браузере открыть адрес https://localhost:5001/authors
И даже можем добавить авторов и убедиться, что они добавляются в базу данных.

Добавление связанных данных

Добавим новый контроллер под именем BookController.fs. Порядок действий тот же, что и раньше. Код контроллера:

namespace EFCoreWebDemo.Controllers
open System.Linq
open Microsoft.AspNetCore.Mvc
open Microsoft.EntityFrameworkCore
open Microsoft.AspNetCore.Mvc.Rendering
open EFCoreWebDemo
type BookController() =
    inherit Controller()

    member this.Index() =
        use context = new EFCoreWebDemoContext()
        let model =
            context.Authors.Include(fun a -> a.Books).AsNoTracking().ToListAsync()
            |> Async.AwaitTask |> Async.RunSynchronously
        this.View(model)

    [<HttpGet>]
    member this.Create() =
        use context = new EFCoreWebDemoContext()
        let authors =
            context.Authors.ToListAsync()
            |> Async.AwaitTask
            |> Async.RunSynchronously
            |> Seq.map (fun a ->
            SelectListItem(
                Value = a.AuthorId.ToString(),
                Text = sprintf "%s %s" (a.FirstName) (a.LastName)
            ))
        this.ViewData.["Authors"] <- authors.ToList()
        this.View()

    [<HttpPost>]
    member this.Create([<Bind("Title, AuthorId")>]book: Book) =
        use context = new EFCoreWebDemoContext()
        context.Books.Add(book) |> ignore       
        context.SaveChanges() |> ignore
        this.RedirectToAction("Index")
       
     

Здесь помимо вольностей, которые я допустил при интерпретации асинхронных операций, о чем написал выше, есть еще пара моментов, о которых следует упомянуть. Оба касаются метода Create() (без параметров). В руководстве у свойства Authors контекста данных вызывается метод Select. Я поначалу сделал так же, но получил ошибку, что-то типа «не удалось эфшарповскую лямбду конвертировать в линковскую». Пришлось обходить это, сначала получив список, а потом обрабатывать его с помощью Seq.map. Я пробовал подсунуть туда query{}, но это тоже не дало результата, по-видимому из-за того, что запросы обрабатываются на стороне базы данных, а там никто понятия не имеет, что такое конструктор SelectListItem. Тем не менее запросы EF прекрасно обрабатывает, как и в других языках.
Другой момент касается контейнера ViewBag. К сожалению, в F# использовать его затруднительно, поскольку этот язык не поддерживает позднее связывание, чего требует этот объект, поэтому его использовать если и получится, то это будет крайне неудобно. Но вместо этого можно использовать ViewData, что я и сделал, при этом надо помнить, что в представление тоже придется внести изменение.

В папке Views создаем папку Book, добавляем туда файлы Index.cshtml и Create.cshtml. Содержимое последнего надо изменить, в связи с заменой контейнера.
Index.cshtml
@model IEnumerable<Author>
@{
    ViewBag.Title = "Authors and their books";
}
<h1>@ViewBag.Title</h1>
@if(Model.Any()){
    <ul>
    @foreach(var author in Model){
        <li>@author.FirstName @author.LastName
            <ul>
            @foreach(var book in author.Books){
                <li>@book.Title</li>
            }
            </ul>
        </li>
    }
    </ul>
}
<div>@Html.ActionLink("New", "create")

Create.cshtml

@model Book
@{
    ViewBag.Title = "New Book";
}

<h1>@ViewBag.Title</h1>

@using(Html.BeginForm()){
  <div class="form-group">
    @Html.LabelFor(model => model.AuthorId)
    @Html.DropDownListFor(model => model.AuthorId, (IEnumerable<SelectListItem>)ViewData["Authors"], string.Empty, new { @class="form-control"})
  </div>
  <div class="form-group">
    @Html.LabelFor(model => model.Title)
    @Html.TextBoxFor(model => model.Title, new { @class="form-control"})
  </div>
  <button type="submit" class="btn btn-default">Submit</button>
}


Запускаем, открываем https://localhost:5001/books, добавляем книги к ранее добавленным автором. Все.



Комментариев нет :

Отправить комментарий