Jak określić warunek wstępny (LSP) w interfejsie w języku C #?

11

Powiedzmy, że mamy następujący interfejs -

interface IDatabase { 
    string ConnectionString{get;set;}
    void ExecuteNoQuery(string sql);
    void ExecuteNoQuery(string[] sql);
    //Various other methods all requiring ConnectionString to be set
}

Warunkiem jest ustawienie / zainicjowanie ConnectionString przed uruchomieniem dowolnej metody.

Ten warunek można w pewnym stopniu osiągnąć, przekazując połączenieString za pośrednictwem konstruktora, jeśli baza danych IDatabela była klasą abstrakcyjną lub konkretną -

abstract class Database { 
    public string ConnectionString{get;set;}
    public Database(string connectionString){ ConnectionString = connectionString;}

    public void ExecuteNoQuery(string sql);
    public void ExecuteNoQuery(string[] sql);
    //Various other methods all requiring ConnectionString to be set
}

Alternatywnie możemy utworzyć parametr connectionString dla każdej metody, ale wygląda to gorzej niż tylko tworzenie klasy abstrakcyjnej -

interface IDatabase { 
    void ExecuteNoQuery(string connectionString, string sql);
    void ExecuteNoQuery(string connectionString, string[] sql);
    //Various other methods all with the connectionString parameter
}

Pytania -

  1. Czy istnieje sposób na określenie tego warunku wstępnego w samym interfejsie? Jest to ważna „umowa”, więc zastanawiam się, czy jest w tym jakaś cecha językowa lub wzorzec (rozwiązanie klasy abstrakcyjnej to raczej hack imo oprócz potrzeby tworzenia dwóch typów - interfejsu i klasy abstrakcyjnej - za każdym razem to jest potrzebne)
  2. Jest to bardziej teoretyczna ciekawość - czy ten warunek wstępny rzeczywiście mieści się w definicji warunku wstępnego, jak w kontekście LSP?
Achilles
źródło
2
Przez „LSP” mówicie o zasadzie substytucji Liskowa? Zasada „jeśli kwak jak kaczka, ale potrzebuje baterii, to nie kaczka”? Ponieważ, jak widzę, jest to bardziej naruszenie ISP i SRP, może nawet OCP, ale nie tak naprawdę LSP.
Sebastien
2
Właśnie dlatego wiesz, że cała koncepcja „ConnectionString musi zostać ustawiona / zainicjalizowana przed uruchomieniem dowolnej z metod” jest przykładem czasowego łączenia blog.ploeh.dk/2011/05/24/DesignSmellTemporalCoupling i należy go unikać, jeśli możliwy.
Richiban
Seemann jest naprawdę wielkim fanem Abstract Factory.
Adrian Iftode

Odpowiedzi:

10
  1. Tak. Począwszy od .Net 4.0 w górę, Microsoft zapewnia umowy na kod . Można ich użyć do zdefiniowania warunków wstępnych w formularzu Contract.Requires( ConnectionString != null );. Jednak, aby zadziałało to w przypadku interfejsu, nadal będziesz potrzebować klasy pomocniczej IDatabaseContract, do której się przyłączy IDatabase, i konieczne jest zdefiniowanie warunków wstępnych dla każdej metody interfejsu, w której będzie on przechowywany. Zobacz tutaj obszerny przykład interfejsów.

  2. Tak , LSP zajmuje się zarówno składniowymi, jak i semantycznymi częściami kontraktu.

Doktor Brown
źródło
Nie sądziłem, że możesz użyć Kodeksów w interfejsie. Podany przez Ciebie przykład pokazuje, że są one używane na zajęciach. Klasy są zgodne z interfejsem, ale sam interfejs nie zawiera żadnych informacji o umowie kodowej (szkoda, naprawdę. To byłoby idealne miejsce do tego).
Robert Harvey
1
@RobertHarvey: tak, masz rację. Z technicznego punktu widzenia potrzebujesz oczywiście drugiej klasy, ale po zdefiniowaniu umowa działa automatycznie dla każdej implementacji interfejsu.
Doc Brown
21

Łączenie i zapytania to dwie osobne kwestie. Jako takie powinny mieć dwa oddzielne interfejsy.

interface IDatabaseConnection
{
    IDatabase Connect(string connectionString);
}

interface IDatabase
{
    public void ExecuteNoQuery(string sql);
    public void ExecuteNoQuery(string[] sql);
}

Zapewnia to IDatabasepołączenie, gdy zostanie użyte, i sprawia, że ​​klient nie zależy od interfejsu, którego nie potrzebuje.

Euforyk
źródło
Mógłby być bardziej precyzyjny na temat „jest to wzór egzekwowania warunków wstępnych poprzez typy”
Caleth
@Caleth: nie jest to „ogólny wzór egzekwowania warunków wstępnych”. Jest to rozwiązanie dla tego konkretnego wymogu zapewnienia, że ​​połączenie nastąpi zanim cokolwiek innego. Inne warunki wstępne będą wymagać różnych rozwiązań (takich jak te, o których wspomniałem w mojej odpowiedzi). Chciałbym dodać do tego wymogu, wyraźnie wolałbym sugestię Euforii od mojej, ponieważ jest ona znacznie prostsza i nie wymaga żadnego dodatkowego komponentu strony trzeciej.
Doc Brown
Szczególne wymagania, że coś się wydarzy, zanim coś innego, mają szerokie zastosowanie. Myślę też, że twoja odpowiedź lepiej pasuje do tego pytania , ale odpowiedź ta może zostać poprawiona
Caleth
1
Ta odpowiedź całkowicie mija się z celem. IDatabaseInterfejs definiuje obiekt zdolny do nawiązywania połączenia z bazą danych, a następnie wykonywanie dowolnych zapytań. Jest to obiekt, który działa jako granica między bazą danych a resztą kodu. Jako taki, obiekt ten musi utrzymywać stan (taki jak transakcja), który może wpływać na zachowanie zapytań. Umieszczenie ich w tej samej klasie jest bardzo praktyczne.
jpmc26
4
@ jpmc26 Żaden z twoich zastrzeżeń nie ma sensu, ponieważ stan może być utrzymany w klasie implementującej IDatabase. Może również odwoływać się do nadrzędnej klasy, która go utworzyła, uzyskując w ten sposób dostęp do całego stanu bazy danych.
Euforyczny
5

Cofnijmy się o krok i spójrzmy na większy obraz tutaj.

Jaka jest IDatabaseodpowiedzialność?

Ma kilka różnych operacji:

  • Analizuj parametry połączenia
  • Otwórz połączenie z bazą danych (system zewnętrzny)
  • Wysyłaj wiadomości do bazy danych; komunikaty nakazują bazie danych zmienić jej stan
  • Otrzymuj odpowiedzi z bazy danych i przekształcaj je w format, z którego może korzystać osoba dzwoniąca
  • Zamknij połączenie

Patrząc na tę listę, możesz pomyśleć: „Czy to nie narusza SRP?” Ale nie sądzę, że tak. Wszystkie operacje są częścią jednej, spójnej koncepcji: zarządzaj stanowym połączeniem z bazą danych (system zewnętrzny) . Ustanawia połączenie, śledzi bieżący stan połączenia (w szczególności w odniesieniu do operacji wykonywanych na innych połączeniach), sygnalizuje, kiedy zatwierdzić bieżący stan połączenia itp. W tym sensie działa jako interfejs API który ukrywa wiele szczegółów implementacji, o które większość rozmówców nie będzie się troszczyć. Na przykład, czy używa HTTP, gniazd, potoków, niestandardowego TCP, HTTPS? Wywołanie kodu nie ma znaczenia; chce tylko wysyłać wiadomości i uzyskiwać odpowiedzi. To dobry przykład enkapsulacji.

Jesteśmy pewni? Czy nie możemy podzielić niektórych z tych operacji? Może, ale nie ma korzyści. Jeśli spróbujesz je rozdzielić, nadal będziesz potrzebować centralnego obiektu, który utrzymuje połączenie otwarte i / lub zarządza obecnym stanem. Wszystkie pozostałe operacje są silnie sprzężone z tym samym stanem, a jeśli spróbujesz je rozdzielić, w końcu i tak delegują się z powrotem do obiektu połączenia. Operacje te są naturalnie i logicznie powiązane ze stanem i nie ma sposobu, aby je rozdzielić. Oddzielenie jest świetne, kiedy możemy to zrobić, ale w tym przypadku tak naprawdę nie możemy. Przynajmniej nie bez bardzo odmiennego, bezstanowego protokołu do rozmowy z DB, co w rzeczywistości znacznie utrudniłoby bardzo ważne problemy, takie jak zgodność z ACID. Ponadto, w trakcie próby oddzielenia tych operacji od połączenia, będziesz zmuszony ujawnić szczegółowe informacje na temat protokołu, na który nie zwracają uwagi osoby dzwoniące, ponieważ będziesz potrzebować sposobu wysłania pewnego rodzaju „arbitralnej” wiadomości do bazy danych.

Zauważ, że fakt, że mamy do czynienia z protokołem stanowym, całkiem solidnie wyklucza twoją ostatnią alternatywę (przekazanie ciągu połączenia jako parametru).

Czy naprawdę potrzebujemy ustawić parametry połączenia?

Tak. Nie możesz otworzyć połączenia, dopóki nie masz ciągu połączenia i nie możesz nic zrobić z protokołem, dopóki nie otworzysz połączenia. Nie ma więc sensu mieć obiektu połączenia bez niego.

Jak rozwiązać problem wymaganego ciągu połączenia?

Problem, który próbujemy rozwiązać, polega na tym, że chcemy, aby obiekt znajdował się w użytecznym stanie przez cały czas. Jakiego rodzaju podmiotu używa się do zarządzania stanem w językach OO? Obiekty , a nie interfejsy. Interfejsy nie mają stanu do zarządzania. Ponieważ problem, który próbujesz rozwiązać, to problem zarządzania stanem, interfejs nie jest tutaj naprawdę odpowiedni. Klasa abstrakcyjna jest znacznie bardziej naturalna. Użyj więc abstrakcyjnej klasy z konstruktorem.

Możesz także rozważyć otwarcie połączenia również podczas konstruktora, ponieważ połączenie jest również bezużyteczne przed jego otwarciem. Wymagałoby to abstrakcyjnej protected Openmetody, ponieważ proces otwierania połączenia może być specyficzny dla bazy danych. Byłoby również dobrym pomysłem, aby ConnectionStringwłaściwość była odczytywana tylko w tym przypadku, ponieważ zmiana ciągu połączenia po otwarciu połączenia byłaby bez znaczenia. (Szczerze mówiąc, i tak uczynię to tylko do odczytu. Jeśli chcesz połączenie z innym ciągiem, stwórz inny obiekt.)

Czy w ogóle potrzebujemy interfejsu?

Przydatny może być interfejs, który określa dostępne wiadomości, które można wysyłać przez połączenie, oraz typy odpowiedzi, które można uzyskać. Pozwoliłoby nam to napisać kod, który wykonuje te operacje, ale nie jest powiązany z logiką otwierania połączenia. Ale o to chodzi: zarządzanie połączeniem nie jest częścią interfejsu „Jakie wiadomości mogę wysłać i jakie wiadomości mogę odzyskać do / z bazy danych?”, Więc ciąg połączenia nie powinien nawet być częścią tego berło.

Jeśli pójdziemy tą drogą, nasz kod może wyglądać mniej więcej tak:

interface IDatabase {
    void ExecuteNoQuery(string sql);
    void ExecuteNoQuery(string[] sql);
    //Various other methods all requiring ConnectionString to be set
}

abstract class ConnectionStringDatabase : IDatabase { 

    public string ConnectionString { get; }

    public Database(string connectionString) {
        this.ConnectionString = connectionString;
        this.Open();
    }

    protected abstract void Open();

    public abstract void ExecuteNoQuery(string sql);
    public abstract void ExecuteNoQuery(string[] sql);
    //Various other methods all requiring ConnectionString to be set
}
jpmc26
źródło
Byłby wdzięczny, gdyby downvoter wyjaśnił powód swojego braku zgody.
jpmc26
Uzgodniony, re: downvoter. To jest właściwe rozwiązanie. Łańcuch połączenia powinien zostać podany w konstruktorze do klasy beton / abstrakcja. Nieuporządkowany proces otwierania / zamykania połączenia nie dotyczy kodu używającego tego obiektu i powinien pozostać wewnętrzny dla samej klasy. Argumentowałbym, że Openmetoda powinna być privatei należy ujawnić chronioną Connectionwłaściwość, która tworzy połączenie i łączy się. Lub ujawnij chronioną OpenConnectionmetodę.
Greg Burghardt
To rozwiązanie jest dość eleganckie i bardzo dobrze zaprojektowane. Myślę jednak, że niektóre z uzasadnień decyzji projektowych są błędne. Głównie w kilku pierwszych akapitach dotyczących SRP. Narusza SRP, nawet jak wyjaśniono w „Na czym polega odpowiedzialność IDatabase?”. Odpowiedzialność, jaką widać w przypadku SRP, to nie tylko czynności, które klasa wykonuje lub którymi zarządza. To także „aktorzy” lub „powody do zmiany”. I myślę, że narusza to SRP, ponieważ „Odbieranie odpowiedzi z bazy danych i przekształcanie ich w format, z którego może korzystać osoba wywołująca”, ma zupełnie inny powód do zmiany niż „Analizuj parametry połączenia”.
Sebastien
Nadal głosuję za tym.
Sebastien
1
A BTW, SOLID nie są ewangelią. Na pewno są one bardzo ważne, o których należy pamiętać przy projektowaniu rozwiązania. Ale MOŻESZ je naruszyć, jeśli wiesz, DLACZEGO to robisz, W JAKI SPOSÓB wpłynie to na twoje rozwiązanie i JAK naprawić problemy z refaktoryzacją, jeśli wpędzi Cię to w kłopoty. Dlatego myślę, że nawet jeśli powyższe rozwiązanie narusza SRP, jest to najlepsze z dotychczasowych.
Sebastien
0

Naprawdę nie widzę powodu, by mieć tutaj interfejs. Twoja klasa bazy danych jest specyficzna dla SQL i naprawdę zapewnia wygodny / bezpieczny sposób upewnienia się, że nie pytasz o połączenie, które nie zostało poprawnie otwarte. Jeśli jednak nalegasz na interfejs, oto jak bym to zrobił.

public interface IDatabase : IDisposable
{
    string ConnectionString { get; }
    void ExecuteNoQuery(string sql);
    void ExecuteNoQuery(string[] sql);
    //Various other methods all requiring ConnectionString to be set
}

public class SqlDatabase : IDatabase
{
    public string ConnectionString { get; }
    SqlConnection sqlConnection;
    SqlTransaction sqlTransaction; // optional

    public SqlDatabase(string connectionStr)
    {
        if (String.IsNullOrEmpty(connectionStr)) throw new ArgumentException("connectionStr empty");
        ConnectionString = connectionStr;
        instantiateSqlProps();
    }

    private void instantiateSqlProps()
    {
        sqlConnection.Open();
        sqlTransaction = sqlConnection.BeginTransaction();
    }

    public void ExecuteNoQuery(string sql) { /*run query*/ }
    public void ExecuteNoQuery(string[] sql) { /*run query*/ }

    public void Dispose()
    {
        sqlTransaction.Commit();
        sqlConnection.Dispose();
    }

    public void Commit()
    {
        Dispose();
        instantiateSqlProps();
    }
}

Użycie może wyglądać następująco:

using (IDatabase dbase = new SqlDatabase("Data Source = servername; Initial Catalog = MyDb; Integrated Security = True"))
{
    dbase.ExecuteNoQuery("delete from dbo.Invoices");
    dbase.ExecuteNoQuery("delete from dbo.Customers");
}
Graham
źródło