Beiträge aus Juni 2010

ADO.NET Entity Framework goes DDD und TDD

Nachdem die Entwicklergemeinde das Entity Framework mit einer Vote of No Confidence gewürdigt und damit deutlich gemacht hat, dass der Persistensbaukausten in die falsche Richtung läuft, hat Microsoft nachgebessert. Plain old CLR Objects (POCO) werden nun unterstützt, was TDD ermöglicht. Das Datenbankschema allerdings bleibt Grundlage für eine EF-Anwendung. Mich hat es gereizt, eine Beispielanwendung zu schreiben. Ich finde den Gedanken faszinierend, eine Datenbankanwendung modell- und testgetrieben zu entwickeln und gleichzeitig von der Persistenzschicht zu abstrahieren, indem ich das Framework die lästigen Routinearbeiten erledigen lasse.

Nun habe ich denn mit einem Datenbankschema begonnen, welches nachfolgend abgebildet ist. Die Entitäten Person und Booking wurden mit Visual Studio modelliert. Eine Person kann mehrere Buchungen eines Ferienhauses machen. Die beiden Entitäten Person und Booking haben jeweils einen Primärschlüssel, ihre IDs. Booking hat einen Sekundärschlüssel, die ID der Person. Dieses Modell sorgt für Konsistenz. Die Datenbank erlaubt es fortan nicht, dass eine Person, auf die eine Buchung verweist, gelöscht oder deren ID verändert wird. Aus diesem Datenbankschema erzeuge ich SQL-Code, um meine Datenbank mit Tabellen zu füllen.

generate_database

Wie man in der folgenden Abbildung sieht, teilt die Solution Anwendung, Modell, Persistenz und Tests in seperate Projekte auf. Das Modell-Projekt hat keine Referenz auf andere Projekte, sondern wird von allen Projekten benutzt. Das habe ich der POCO-Unterstützung zu verdanken. Mit EF4 ist es möglich, mit Objekten zu arbeiten, die die Persistenz ignorieren.

solution_model

Mit unabhängigen POCO wird die Verantwortung allerdings dem Modell übertragen, dass nun selber für Konsistenz sorgen muss. Die Setter Methode Personder Klasse Booking, muss _personID im folgenden Code auffrischen. War _personID vorher ein anderes Objekt, muss das vorherige Objektthis aus der Liste der Buchungen herausnehmen. _personID muss hingegen this zur Liste der Buchungen hinzufügen.

namespace Model
{
    public partial class Booking : IValidate
    {

        #region Primitive Properties

        public virtual int ID { get; set; }
        public virtual DateTime StartDate { get; set; }
        public virtual DateTime EndDate { get; set; }

        public virtual int PersonID
        {
            get { return _personID; }
            set
            {
                if (value != _personID)
                {
                    if (_person != null && _person.ID != value)
                    {
                        Person = null;
                    }

                    _personID = value;
                }
            }
        }
        private int _personID;

        #endregion

        #region Navigation Properties

        public virtual Person Person
        {
            get
            {
                return _person;
            }
            set
            {
                if (!Object.ReferenceEquals(_person, value))
                {
                    var existingValue = _person;

                    _person = value;

                    if (value != null)
                    {
                        _personID = value.ID;
                    }
                    if ((existingValue != null) && (existingValue.Bookings.Contains(this)))
                    {
                        existingValue.Bookings.Remove(this);
                    }

                    if ((value != null) && !value.Bookings.Contains(this))
                    {
                        value.Bookings.Add(this);
                    }
                }
            }
        }

        private Person _person;

        #endregion

        #region IValidate Members
        void IValidate.Validate(ChangeAction action)
        {
            if (action == ChangeAction.Insert)
            {
                if (this.Person == null)
                    return;

                if (this.Person.Bookings.Count(c => c.StartDate == this.StartDate ) > 1)
                    throw new InvalidOperationException(
                "The booking overlaps with another booking of this person");
            }
        }

        #endregion
    }
}

Andererseits fügt Person im folgenden Code im Konstruktor der Liste von Buchungen einen Delegaten hinzu, der aufgerufen wird, wenn eine Buchung hinzugefügt oder entfernt wird. Einer neu hinzugefügten Buchung wird this zugeordnet, einer entfernten Buchung wird this entzogen.

namespace Model
{
    public partial class Person : IValidate
    {
        public Person()
        {
            _bookings = new ObservableCollection<Booking>();
            ((ObservableCollection<Booking>)_bookings).CollectionChanged += (sender, e) =>
            {
                if (e.NewItems != null)
                    foreach (Booking item in e.NewItems)
                        if (item.Person != this)
                            item.Person = this;

                if (e.OldItems != null)
                    foreach (Booking item in e.OldItems)
                        if (item.Person == this)
                            item.Person = null;
            };

        }

        public virtual int ID { get; set; }
        public virtual string FirstName { get; set; }
        public virtual string LastName { get; set; }

        #region Navigation Properties

        public virtual ICollection<Booking> Bookings
        {
            get
            {
                return _bookings;
            }
        }

        private  ICollection<Booking> _bookings;
        #endregion

        #region IValidate Members

        public void Validate(ChangeAction action)
        {
        }

        #endregion
    }

}

Als nächstes muss der Datenbankzugriff gekapselt werden, um vollständige Testbarkeit zu gewährleisten. Das erledigt man überlichweise mit einem Repository. Da ich testgetrieben entwickeln will, abeitet mein Repository auf einem austauschbaren Context. Für die Tests wird ein Fakekontext benutzt, für den Produktionscode der direkte Datenbankzugriff. Der Context ist eine Implementierung des nachfolgenden Interface IEntities:

namespace Model
{
    public interface IEntities : IDisposable
    {
        IObjectSet<Person> Persons { get; }
        IObjectSet<Booking> Bookings { get; }
        int SaveChanges();

    }
}

Hier die Klasse Repository:

namespace Model
{
    public class Repository
    {
        private IEntities _context;

        public Repository(IEntities context)
        {
            if (context == null)
                throw new ArgumentNullException("context was null");
            _context = context;
        }

        public void AddBooking(Booking b)
        {
            _context.Bookings.AddObject(b);
        }

        public void AddPerson(Person p)
        {
            _context.Persons.AddObject(p);
        }

        public Person GetPersonByID(int id)
        {
            return _context.Persons.First(p => p.ID == id);
        }

        public void SaveChanges()
        {
            _context.SaveChanges();
        }
    }
}

Wie der Kontext fake- und datenbankseitig implementiert wird, verschweige ich hier mal. Die Schablone dafür habe ich mir von diesem Blog abgeschaut.

Ein Beispieltest beweist mir, dass eine Exception geschmissen wird, wenn eine Person 2 Buchungen macht, deren Startdatum gleich sind:

namespace Tests
{
    [TestClass]
    public class CommentTests
    {
        private IEntities _context;

        [TestInitialize]
        public void TestSetup()
        {
            Person p = new Person()
            {
                FirstName = "Jonathan",
                LastName = "Aneja",
                ID = 1
            };

            Booking b = new Booking()
            {
                StartDate = new DateTime(2010, 10, 1),
                EndDate = new DateTime(2010, 10, 8),
                Person = p
            };

            _context = new FakeEntities();
            var repository = new Repository(_context);

            repository.AddPerson(p);
            repository.SaveChanges();
        }

        [TestMethod]
        [ExpectedException(typeof(InvalidOperationException))]
        public void AttemptedOverlappingBookings()
        {
            var repository = new Repository(_context);
            Booking b2 = new Booking()
            {
                StartDate = new DateTime(2010, 10, 1),
                EndDate = new DateTime(2010, 10, 8),
                Person = repository.GetPersonByID(1)
            };

            repository.AddBooking(b2);
            repository.SaveChanges();

        }
    }
}

Hier die Frucht des Ganzen: ein wunderschöner objektorientierter Code, um einen Benutzer und dessen Buchung in der Datenbank anzulegen:

namespace Application
{
    class Program
    {
        static void Main(string[] args)
        {
            Person p1 = new Person()
            {
                FirstName = "aaaaa",
                LastName = "bbbb"
            };

            Booking b = new Booking()
            {
                StartDate = new DateTime(2013, 10, 1),
                EndDate = new DateTime(2013, 10, 8),
                Person = p1
            };

            var _context = new VacationHomeBookingEntities();
            var repository = new Repository(_context);

            repository.AddBooking(b);

            try
            {
                repository.SaveChanges();
            }
            catch (InvalidOperationException e)
            {
                Console.WriteLine("{0} Exception caught.", e);
                Console.ReadKey();

                return;
            }

        }
    }
}
 

Gospiel-Datenbank frei nach MVVM

Nicht, dass das Model View View Model (MVVM) der Weisheit letzter Schluß wäre, um wartbare Anwendungen in WPF zu schreiben. Es ist lediglich ein Muster, dass sich in letzter Zeit eingebürgert hat und sich ganz gut anhört: Es gibt ein getrenntes Modell, dass es bei MVC auch stets gibt. Das hat den Vorteil, dass man die Intelligenz der Anwendung testgetrieben entwickeln kann. Aber auch Feinde der testgetriebenen Anwendung sehen in solch einer Trennung Vorteile, bleibt doch der Modellcode rein und lesbar und die Anwendung kann nach einzelnen Aspekten entwickelt werden. Schließlich kann reiner Modellcode den Lauf der Zeiten besser überdauern, da man ihn direkter und einfacher ändern kann, statt irrelevante oder oberflächliche Dinge stets mit in die Betrachtung ziehen zu müssen. Das View-Model ist ein weiteres Modell, allerdings dient es nur der View und beschreibt, nach welchem Modell diese funktioniert. Es gehört also nur zu dieser Sicht auf die Daten.

Um das MVVM-Muster auszuprobieren, programmiere ich mir wieder einen lange gehegten Wunsch. Ich habe 40.000 Go-Spiele als einzelne Datei und als ebenso viele Dateien vorliegen und würde mir gerne eine komfortable Suche gönnen, statt in einer 57 MByte großen Datei mit einem Texteditor nach Spielen zu fischen.

Das Modell ist extrem simpel und für dieses Modell lohnt sich der Aufwand natürlich nicht. Sei es drum:

namespace mvvmfilter
{
    class GoGame
    {
        public string Player1 { get; set; }
        public string Player2 { get; set; }
        public string Date { get; set;  }
        public string Tournament { get; set; }
        public string Result { get; set; }
        public string Content { get; set; }
    }
}

Interessanter ist das View-Modell. Es ist der umfangreichste Code in der Projektmappe und das zeigt schon, dass meine Anwendung hauptsächlich aus einer Oberfläche besteht. Das ist auch genau meine Absicht: eine komfortablere Suche. Der Code spiegelt also genau meine Absicht wieder, prima!

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.ComponentModel;
using mvvmfilter;
using System.Collections.ObjectModel;
using Microsoft.Practices.Composite.Wpf.Commands;
using System.Windows.Forms;
using System.IO;

namespace mvvmfilter
{
    class ViewModel : INotifyPropertyChanged
    {
        public ObservableCollection<GoGame> GoGameList { get; set; }

        public ObservableCollection<GoGame> FilteredList
        {
            get
            {
                return GetFilteredList(GoGameList);
            }
        }

        public ViewModel()
        {
            GoGameList = new ObservableCollection<GoGame>();

            SaveEquipmentCommand = new DelegateCommand<object>(SaveEquipment);

            foreach (fscharf.GoGame game in fscharf.Games)
                GoGameList.Add(new GoGame() { Player1 = game.PlayerWhite, Player2 = game.PlayerBlack, Date = game.Date, Tournament = game.Tournament , Result = game.Result, Content = game.Content});

        }

        public DelegateCommand<object> SaveEquipmentCommand { get; set; }

        private void SaveEquipment(object pObject)
        {
            Stream myStream;
            SaveFileDialog saveFileDialog1 = new SaveFileDialog();

            saveFileDialog1.Filter = "sgf files (*.sgf)|*.sgf|All files (*.*)|*.*";
            saveFileDialog1.RestoreDirectory = true;

            if (saveFileDialog1.ShowDialog() == DialogResult.OK)
            {
                if ((myStream = saveFileDialog1.OpenFile()) != null)
                {

                    string content = ((GoGame)pObject).Content;

                    System.IO.StreamWriter file = new System.IO.StreamWriter(myStream);
                    file.WriteLine(content);

                    file.Close();

                }
            }
        }

        private ObservableCollection<GoGame> GetFilteredList(ObservableCollection<GoGame> pOrginalGoGameList)
        {
            ObservableCollection<GoGame> filteredGoGamelist = new ObservableCollection<GoGame>();

            var x = from p in pOrginalGoGameList
                    where
                        FilterPlayer1Method(p, this.FilterPlayer1) &&
                        FilterPlayer2Method(p, this.FilterPlayer2) &&
                        FilterDateMethod(p, this.FilterDate) &&
                        FilterTournamentMethod(p, this.FilterTournament)
                    select p;

            foreach (var u in x)
                filteredGoGamelist.Add(u);

            return filteredGoGamelist;
        }
        public static bool FilterPlayer1Method(GoGame goGame, string compare)
        {
            if (goGame == null) return false;
            if (compare == null) return false;
            if (compare.Equals(string.Empty)) return true;
            return (goGame.Player1.ToUpper().StartsWith(compare.ToUpper()));
        }

        public static bool FilterPlayer2Method(GoGame goGame, string compare)
        {
            if (goGame == null) return false;
            if (compare == null) return false;
            if (compare.Equals(string.Empty)) return true;
            return (goGame.Player2.ToUpper().StartsWith(compare.ToUpper()));
        }

        public static bool FilterDateMethod(GoGame goGame, string compare)
        {
            if (goGame == null) return false;
            if (compare.Equals(string.Empty)) return true;

            return (goGame.Date.ToUpper().StartsWith(compare.ToUpper()));
        }

        public static bool FilterTournamentMethod(GoGame goGame, string compare)
        {
            if (goGame == null) return false;

            if (compare.Equals(string.Empty)) return true;
            return (goGame.Tournament.ToUpper().StartsWith(compare.ToUpper()));
        }

        private string _FilterPlayer2 = string.Empty;
        public string FilterPlayer2
        {
            get
            {
                return _FilterPlayer2;
            }
            set
            {
                _FilterPlayer2 = value;
                NotifyPropertyChanged("FilterPlayer2");
                NotifyPropertyChanged("FilteredList");
            }
        }

        private string _FilterPlayer1 = string.Empty;
        public string FilterPlayer1
        {
            get
            {
                return _FilterPlayer1;
            }
            set
            {
                _FilterPlayer1 = value;
                NotifyPropertyChanged("FilterPlayer1");
                NotifyPropertyChanged("FilteredList");
            }
        }

        private string _FilterDate = string.Empty;
        public string FilterDate
        {
            get
            {
                return _FilterDate;
            }
            set
            {
                _FilterDate = value;
                NotifyPropertyChanged("FilterDate");
                NotifyPropertyChanged("FilteredList");
            }
        }

        private string _FilterTournament = string.Empty;
        public string FilterTournament
        {
            get
            {
                return _FilterTournament;
            }
            set
            {
                _FilterTournament = value;
                NotifyPropertyChanged("FilterTournament");
                NotifyPropertyChanged("FilteredList");
            }
        }

        #region INotifyPropertyChanged Member

        public event PropertyChangedEventHandler PropertyChanged;

        protected void NotifyPropertyChanged(string propertyName)
        {
            if (this.PropertyChanged != null)
            {
                this.PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
            }
        }

        #endregion
    }
}

Das Viewmodel implementiert das Interface INotifyPropertyChanged und besteht im wesentlichen daraus, Spiele aus einer Originalcollection bei Veränderung der Suchparameter in eine ObservableCollection zu pumpen, welche mit der View über Data Binding verknüft ist. Außerdem reagiert das View-Model auf Benutzereingaben in die Controls des Filters.

Den Code, um die Orginalcollection zu befüllen, habe ich aus Spaß an der Freude mit F# geschrieben:

module mvvmfilter.fscharf

#light

open System.IO

type GoGame = class
      val PlayerWhite: string
      val PlayerBlack: string
      val Date  : string
      val Tournament : string
      val Content : string
      val Result : string
      new (playerWhite, playerBlack, date, tournament, result, content) =
        { PlayerWhite = playerWhite ; PlayerBlack = playerBlack; Date = date; Tournament = tournament; Result = result; Content = content;}
end

let Games = new System.Collections.Generic.List<GoGame>()

let files = Directory.GetFiles(@"C:\Users\Oliver\Downloads\FreeProGames\AllSeperated")

let extract (ls: string, ps: string) =
    let indexpw1 = ls.IndexOf(ps)
    if indexpw1 < 0 then
        ""
    else
        let indexpw1x = indexpw1 + 3
        let indexpw2 = ls.IndexOf("]", indexpw1x)
        if indexpw2 > 0 then
            ls.Substring(indexpw1x, indexpw2-indexpw1x)
        else
            ""

let newgame (gamestr: string) =
    Games.Add( new GoGame(extract(gamestr,"PW["), extract(gamestr,"PB["), extract(gamestr,"DT["), extract(gamestr,"EV["), extract (gamestr, "RE["), gamestr ))

using (File.OpenText(@"C:\Users\Oliver\Downloads\FreeProGames\collection\Collection.sgf"))
    (fun f->
        while not f.EndOfStream do
            let content = f.ReadToEnd()

            let gamestrings = content.Split[|'('|]
            for i = 0 to gamestrings.Length - 1 do
                let gamestring = "(" + gamestrings.[i]
                newgame(gamestring)

    )

Ohne, das Buch Foundations of F# von Robert Pickering, das mir freundlicherweise mein Arbeitgeber auslieh, wäre ich gar nicht weitergekommen. Online-Quellen, selbst die von Microsoft sind sehr unergiebig. Dafür gab das Buch alles, was ich brauchte, her.
Im View-Model kopiere die in F# bestückte Collection in eine C#-Collection, die dann der Anwendung als Datengrundlage dient. Ich habe festgestellt, dass der Datenimport mit F# unvergleichlich viel schneller geht, wenn ich alle Spiele aus der 57 MB großen Datei hole (einige Sekunden), statt aus 40.000 einzelnen Dateien. Die Festplatte muss da ein sehr kleines, Nadelöhr darstellen. So dauert es eine gefühlte halbe Stunde:

for filepath in files do
    using (File.OpenText(filepath))
        (fun f ->
            while not f.EndOfStream do
                let line = f.ReadToEnd()

                let game = new GoGame(extract(line,"PW["), extract(line,"PB["), extract(line,"DT["), extract(line,"EV["), extract (line, "RE["), line )
                Games.Add(game)

        )

So sieht sie dann aus, die Oberfläche:
mvvm_gui

Es gibt nicht so viele Gospieler, die außerhalb ihrer Karriere eine Bedeutung für die Nachwelt erlangt haben. Zwei Persönlichkeiten, die aber für ihren unkomplizierten Stil bekannt sind, sind Takagawa Kaku und Otake Hideo. Also lade ich mir mal ein Spiel von diesen zwei von einem NHK-Turnier herunter. Diese Turniere haben einen sehr knappen Zeitrahmen, so dass schon gar keine Zeit existiert, um sich komplizierte Varianten auszudenken. Schnelle Spiele könnten also durch gute Form beeindrucken. Das Nachlegen der ersten Partie hat einigen Spaß bereitet:
spaß_am_nachlegen