Объясните общие понимания работы программы с источниками данных

Пишу систему, у которой в начале входа нужно выбирать БД(и настроить ее) или файловое хранение(и тоже настроить по типу места базирования).

По выбранному источнику хранения программа должны работать от начала и до конца своей работы.

Исходя из этого возникли вопросы как заложить архитектуру использования программой - источника данных.

То есть:

  1. Как программа должна хранить состояние выбранного источника данных, чтобы можно им пользоваться из любой части программы.
  2. Как взаимодействовать с выбранным источником.
  3. Как взаимодействовать с выбранным источником если нужны разные действия, запросы, выборки, запись и т.д.
  4. Какие использовать паттерны для организации работы программы с источником данных
  5. и то что еще может быть при работе с источниками данных

Разъясните пожалуйста принципы работы программы при выборе разных источников данных или поделитесь статейками, темами, где можно про это прочитать.


Ответы (2 шт):

Автор решения: rotabor

Центр документации по данным .NET

Центр документации Entity Framework

Работа с данными в Visual Studio

Работа с БД в C#

Паттерн Репозиторий - METANIT.COM

Ща как расскажу!

Для начала ответы на вопросы.

  1. Не должна. Состояние источника данных (ИД) можно получить в любой момент от него самого, и дублировать его в приложении не нужно.
  2. Для этого внутри приложения должен быть драйвер ИД - объект, через который происходит всё взаимодействие. В случае использования Entity Framework - это Context.
  3. Через драйвер. Драйвер может быть даже поверх Entity Framework. В драйвере нужно с удобным интерфейсом реализовать действия с данными, востребованные в приложении.
  4. Паттерн - это некий готовый алгоритм, "стандартное решение". Не всегда нужно реализовывать такое решение в полном объёме, от ненужных частей и функций стоит отказаться. Подходящий паттерн упомянул @AlexanderPetrov в своём комментарии.
  5. Абзац после пятого вопроса. Драйвер должен позволять использовать различные источники данных. Поэтому доступ к данным нужно сделать двухступенчатым - под "пользовательским" драйвером должны быть драйверы для каждого типа ИД.

Советую разрабатывать приложение, начиная с самых простых функций работы с ИД, и постепенно добавлять необходимые более сложные функции. Так будет выстраиваться интерфейс драйвера.

P. S. Про файловай источники данных. Начните с простого, например, с формата CSV. Это будет аналог таблицы в БД. Здесь будет очень легко создать драйвер, подходящий для обоих типов ИД. Не делайте большие объёмы, чтобы можно было начать с (временного) хранения массива данных в памяти.

Потом можно перейти к фомату XML (или JSON), который позволяет хранить иерархические структуры данных, что будет аналогом нескольких связанных таблиц (реляционной) БД.

В общем, это роман с продолжением, которое я писать не очень хочу. Евгений ниже правильно прокомментировал, в основном. Читайте по первым двум ссылкам, там найдёте много полезного.

→ Ссылка
Автор решения: EvgeniyZ

Смотрите, основная суть тут в определении общих методов и свойств этих двух источников данных. К примеру, база и файл, нам надо "добавить" туда данные (Add) и "получить" их (Get), также наверно будут "изменить", "сохранить", "загрузить", и так далее, это уже зависит от вашего проекта. Разобравшись с тем, что связывает их, надо теперь создать "контракт", некий интерфейс, который будет требовать от класса реализацию всех этих методов и свойств. В итоге, у нас будет что-то такое:

public interface IDataRepository
{
    void Add(string value);
    string Get(string key);
}

Ну а дальше просто реализуйте под каждый источник данных свой класс, ну к примеру, пусть будет что-то такое:

public class DBRepository(string login, string password) : IDataRepository
{
    private readonly Dictionary<string, string> _data = new();

    public void Add(string value)
    {
        _data.Add(value, value);
    }

    public string Get(string key)
    {
        return _data.TryGetValue(key, out var value) ? value : string.Empty;
    }
}

public class FileRepository(string filePath) : IDataRepository
{
    public void Add(string value)
    {
        File.AppendAllText(filePath, $"{value}\n");
    }

    public string Get(string key)
    {
        return File.ReadAllText(filePath).Contains(key) ? key : string.Empty;
    }
}

На реализацию не смотрите, я тут написал ее так, от балды, но суть думаю уловили, каждый класс имеет свою конкретную реализацию под конкретный источник данных.

Теперь, имея такое разделение, вы можете создать свойство/поле с типом данного интерфейса и в реальном времени задавать нужное значение. Например:

public IDataRepository? DataRepository { get; set; }

public void ChangeRepository(string type)
{
    DataRepository = type switch
    {
        "DB" => new DBRepository("login", "password"),
        "File" => new FileRepository("data.txt"),
        _ => throw new Exception("Invalid repository type")
    };
}

Собственно вот основной подход.
Теперь что касается остальных ваших вопросов:

  1. Как программа должна хранить состояние выбранного источника данных.
    Зависит очень сильно от того, как вы проектируете проект изначально. К примеру:

    • У вас может быть свой UI под каждый источник данных, а это значит, что под каждый UI стоит сделать свою ViewModel в которой и хранить конкретный объект.

    • Или у вас могут задаваться эти данные за пределами приложения (например, в JSON), тогда уже вам нужна либо "фабрика" (если вы не можете хранить тип конкретного класса), или прям в файле устанавливаете тип и десериализуете его сразу в нужный репозиторий ({ "$type" : "FileRepository", "Path": "file.txt" }. Далее, получив конкретный тип, вы можете сделать его обертку как Singleton и обращаться к ней где вам надо. Аналогично вы можете сделать через IoC контейнеры (вроде вы их знаете), где вам просто достаточно зарегистрировать этот тип после получения.

    Ну и так далее, вариантов тут действительно много. В вашем случае мне кажется лучше всего подойдет создание некого класса "обертки", который будет в себе хранить сам IDataRepository, а также иметь метод его изменения, что-то вроде этого:

    public class DataRepositoryService
    {
        public IDataRepository? Data { get; private set; }
        public void SetRepository(IDataRepository dataRepository)
        {
            Data = dataRepository;
        }
    }
    

    Опять, пишу очень упрощенно, чтоб вы понимали логику. И вот у вас уже класс, который руководит всем этим, вам достаточно его один раз создать и дальше передавать куда хотите, ну или сделать его синглтоном, добавив к примеру что-то такое:

    private static readonly Lazy<DataRepositoryService> _instance = new(() => new DataRepositoryService());
    public static DataRepositoryService Instance => _instance.Value;
    
    private DataRepositoryService()
    {
    }
    

    Взаимодействие тогда будет таким: DataRepositoryService.Instance.SetRepository(...);, а если этот класс будет в контейнере, то вам вовсе достаточно будет попросить в любом месте экземпляр данного класса. Собственно вот вам простое хранение.

  2. Как взаимодействовать с выбранным источником.
    Как и с любым другим классом, получите объект, вызовите нужные свойства/методы, получите нужный результат. Самое главное, через интерфейс. Ну а если у вашего класса есть дополнительная логика (чего не желательно), то приведите к конкретному типу (FileRepisitory)Data;.

  3. Остальные вопросы вроде и так затронул в ответе.

Тут у вас может возникнуть еще вопрос про UI, как его правильно создавать... Ох, а тут все довольно спорно, ибо у каждого свой подход и предпочтения, но давайте сделаем что-то простое.

  1. Делаем интерфейс, который обобщит наши ViewModel (вы вроде знаете про них, если нет, пишите, объясню). В нем требуем событие, которое оповестит главную VM об изменении источника данных:

    public interface IDataViewModelBase
    {
        event Action<IDataRepository>? Created;
    }
    
  2. Реализуем под каждого источника свою VM, в которой прописываем все нужные данные для получения:

    public partial class FileDataViewModel : IDataViewModelBase
    {
        public event Action<IDataRepository>? Created;
    
        public string FilePath { get; set; } = string.Empty;
    
        [RelayCommand]
        private void Create()
        {
            Created?.Invoke(new FileRepository(FilePath));
        }
    }
    
    public partial class DBDataViewModel : IDataViewModelBase
    {
        public event Action<IDataRepository>? Created;
    
        public string Login { get; set; } = string.Empty;
        public string Password { get; set; } = string.Empty;
    
        [RelayCommand]
        private void Create()
        {
            Created?.Invoke(new DBRepository(Login, Password));
        }
    }
    

    На [RelayCommand] внимание не обращайте, это генерация ICommand от библиотеки CommunityToolkit.Mvvm. Вы у себя сделайте свои команды (вроде вы про Reactive интересовались, вот можно вроде через него сделать). Но не суть... В коде как видите простые свойства и метод, который будет вызван по нажатию кнопки.

  3. Далее пишем главную VM, в ней будем хранить список с названием всех репозиториев (обычно он получается динамически, в моем примере это просто string), свойство текущего выбранного типа, ну и метод, который будет вызван при изменении свойства, и метод, который уже будет вызываться по событию конкретной VM.

    public partial class MainViewModel : ObservableObject
    {
        public string[] Repositories => [ "File", "DB" ];
    
        [ObservableProperty]
        private IDataViewModelBase? _selectedViewModel;
    
        private string _selectedRepository = string.Empty;
        public string SelectedRepository
        {
            get => _selectedRepository;
            set
            {
                _selectedRepository = value;
                OnSelectionChanged(value);
            }
        }
    
        private void OnSelectionChanged(string value)
        {
            if (SelectedViewModel is not null)
            {
                SelectedViewModel.Created -= SelectedViewModelCreated;
            }
    
    
            SelectedViewModel = value switch
            {
                "File" => new FileDataViewModel(),
                "DB" => new DBDataViewModel(),
                _ => throw new NotImplementedException()
            };
    
            SelectedViewModel.Created += SelectedViewModelCreated;
        }
    
        private void SelectedViewModelCreated(IDataRepository repository)
        {
            DataRepositoryService.Instance.SetRepository(repository);
        }
    }
    
  4. Делаем UI. Подход тут как у страниц, где в ресурсах мы сопоставляем при помощи DataTemplate тип с нужным видом (обычно этот вид разносят по отдельным UserControl), а в самом XAML окна/страницы привязываем ContentPresenter к нужному свойству.

    <Window.Resources>
        <DataTemplate DataType="{x:Type local:DBDataViewModel}">
            <StackPanel >
                <Grid Margin="10">
                    <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="Auto"/>
                        <ColumnDefinition/>
                    </Grid.ColumnDefinitions>
                    <Grid.RowDefinitions>
                        <RowDefinition/>
                        <RowDefinition/>
                    </Grid.RowDefinitions>
    
                    <TextBlock Grid.Row="0" Grid.Column="0" Text="Логин: " FontWeight="Medium" VerticalAlignment="Center"/>
                    <TextBlock Grid.Row="1" Grid.Column="0" Text="Пароль: " FontWeight="Medium" VerticalAlignment="Center"/>
                    <TextBox Grid.Row="0" Grid.Column="1" Text="{Binding Login}" />
                    <TextBox Grid.Row="1" Grid.Column="1" Text="{Binding Password}" />
                </Grid>
    
                <Button Margin="0 5" HorizontalAlignment="Stretch" Content="Создать"  Style="{DynamicResource DefaultAccentButtonStyle}"  Command="{Binding CreateCommand}"/>
            </StackPanel>
        </DataTemplate>
    
        <DataTemplate DataType="{x:Type local:FileDataViewModel}">
            <StackPanel >
                <Grid Margin="10">
                    <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="Auto"/>
                        <ColumnDefinition/>
                    </Grid.ColumnDefinitions>
                    <Grid.RowDefinitions>
                        <RowDefinition/>
                        <RowDefinition/>
                    </Grid.RowDefinitions>
    
                    <TextBlock Grid.Row="0" Grid.Column="0" Text="Путь: " FontWeight="Medium" VerticalAlignment="Center"/>
                    <TextBox Grid.Row="0" Grid.Column="1" Text="{Binding FilePath}" />
                </Grid>
    
                <Button Margin="0 5" HorizontalAlignment="Stretch" Content="Создать"  Style="{DynamicResource DefaultAccentButtonStyle}"  Command="{Binding CreateCommand}"/>
            </StackPanel>
        </DataTemplate>
    </Window.Resources>
    
    <StackPanel Margin="5">
        <ComboBox ItemsSource="{Binding Repositories}" SelectedItem="{Binding SelectedRepository}"/>
        <ContentPresenter Content="{Binding SelectedViewModel}"/>
    </StackPanel>
    

    В итоге результат наш будет таким:

    Result

    Как видите, простое изменение ComboBox меняет и вид, ну а там уже при заполнении данных и нажатию на кнопку, будет вызвано событие, а оно в свою очередь вызовет SelectedViewModelCreated метод в котором я прописал изменение репозитория в сервисе (синглтон), ну а у вас своя реализация.

В общем, я вроде показал все, что вам потребуется, дальше уже разбирайтесь и подстраивайте под свои нужды. Главное, не лепите все в одну кучу, как говориться, разделяйте и властвуйте)
Удачи в изучении C#!

→ Ссылка