Подтвердить что ты не робот

Общий репозиторий с уровнем доступа к данным

Я создаю новый проект с использованием бизнес-объектов (Employee, Product). Из-за ограничений я не использую LINQ to SQL или любой ORM Mapper.

Мне нужно передать код уровня доступа к данным. Я заинтересован в использовании "Образцового шаблона".

В соответствии с тем, что я понимаю, мне нужно создать общий репозиторий IRepository, который реализуется всеми репозиториями ProductRepository, EmployeeRepository.

Что меня смущает, так это то, что разные бизнес-объекты имеют разные требования. Например:

ProductRepository

 GetAllProducts ();
 GetProductById (int id);
 GetProductByMaxPrice (double price);
 GetProductByNamePrice (string name, double Price);
 Get... (...);

EmployeeRepository

 GetEmployeeByAge ();
 GetEmployeeByJob (string description);
 GetEmployeeBySalary (double salary);
 Get... (...); //and so on

Как создать общий репозиторий, который отвечает различным требованиям к доступу к данным для разных объектов?

Я прочитал много теорий о шаблоне репозитория, но очень ценю рабочий пример.

Кроме того, если я могу создать все репозитории, используя общий репозиторий, использование шаблона factory становится простым. Например:

interface IRepository
{
    ....
}

ProductRepository : IRepository
{
    ....
}

EmployeeRepository : IRepository
{
    ....
}

Затем мы можем эффективно использовать шаблон factory, как:

IRepository repository;
repository = new ProductRepository ();
repository.Call_Product_Methods ();

repository = new EmployeeRepository ();
repository.Call_Employee_Methods ();
4b9b3361

Ответ 1

Шаблон репозитория - отличный шаблон для использования, но если он сделан неправильно, вместо облегчения вашей жизни, это будет огромная боль!

Итак, лучший способ сделать это (поскольку вы не хотите использовать EF или другую ORM), это создать общий интерфейс, а затем базовую абстрактную реализацию. Таким образом, вам не нужно кодировать каждый репозиторий, вы можете просто создать экземпляр их по типу!

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

Если вы хотите использовать шаблон репозитория, я также предлагаю использовать шаблон IUnitOfWork и хранить его отдельно от репозитория.

Оба интерфейса должны выглядеть примерно так:

Очень простой IUnitOfWork:

Public interface IUnitOfWork
{
    bool Save();
}

И они, интерфейс репозитория, используя общий:

public interface IRepository<TEntity> : IDisposable where TEntity : class

    IUnitOfWork Session { get;}

    IList<TEntity> GetAll();
    IList<TEntity> GetAll(string[] include);
    IList<TEntity> GetAll(Expression<Func<TEntity, bool>> predicate);

    bool Add(TEntity entity);
    bool Delete(TEntity entity);
    bool Update(TEntity entity);
    bool IsValid(TEntity entity);
}

Методы .Add(),.Delete() не должны отправлять что-либо в базу данных, но они всегда должны отправлять изменения в IUnitOfWork (которые вы можете реализовать в своем классе DAL), и только при вызове. Save() метода IUnitOfWork вы сохраните вещи в базе данных.

Я реализовал свой класс репозитория, используя EntityFramework, и это упрощает работу, но вы можете сделать это любым способом.

Код, который вы будете использовать, будет примерно таким:

void SomeMethod()
{
    using (IUnitOfWork session = new YourUnitOfWorkImplementation())
    {
        using (var rep = new Repository<Client>(session))
        {
            var client1 = new Client("Bob");
            var client2 = new Cliente("John");
            rep.Add(client1);
            rep.Add(client2);
            var clientToDelete = rep.GetAll(c=> c.Name == "Frank").FirstOrDefaut();
            rep.Delete(clientToDelete);

            //Now persist the changes to the database
            session.Save();

        {
    {
}

Как я уже сказал, с EF и DbContext это намного проще, вот небольшая часть моего класса Repository:

public class Repository : Component, IRepository
{


    protected DbContext session;
    {
        get
        {
            if (session == null)
                throw new InvalidOperationException("A session IUnitOfWork do repositório não está instanciada.");
            return (session as IUnitOfWork);
        }
    }

    public virtual DbContext Context
    {
        get
        {
            return session;
        }
    }

    public Repository()
        : base()
    {
    }

    public Repository(DbContext instance)
        : this(instance as IUnitOfWork)
    {


    #endregion


    public IList<TEntity> GetAll<TEntity>() where TEntity : class
    {
        return session.Set<TEntity>().ToList();
    }


    public bool Add<TEntity>(TEntity entity) where TEntity : class
    {
        if (!IsValid(entity))
            return false;
        try
        {
            session.Set(typeof(TEntity)).Add(entity);
            return session.Entry(entity).GetValidationResult().IsValid;
        }
        catch (Exception ex)
        {
            if (ex.InnerException != null)
                throw new Exception(ex.InnerException.Message, ex);
            throw new Exception(ex.Message, ex);
        }
    } ...

Таким образом вам не нужно создавать GetEmployeeByAge, вы просто напишете:

IEnumerable<Employee> GetEmployee(int age)
{
 return  rep.GetAll<Employee>(e=> e.Age == age);
}

Или вы можете просто позвонить напрямую (нет необходимости создавать метод)

Ответ 2

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

Где я лично рисую линию, это методы Insert, Update и Delete. Во всех, кроме самых простых случаях, мы должны определить, что мы делаем. Да, создание нового Supplier может означать вызов операции Insert. Но большинство нетривиальных случаев, которые вы собираетесь делать другими делами.

Поэтому при разработке репозиториев я считаю, что лучше всего определить, какие действия вы собираетесь делать, и методы, названные именно так:

CreateClient(); // Might well just be a single Insert.... might involve other operations
MoveClientToCompany(); // several updates right here
GetContractsForClient(); // explicitly returns contracts belonging to a client

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

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

Даже свойство this[int] default имеет сложные проблемы, когда нам нужно ответить на то, что мы собираемся вернуть. Если это большой объект с множеством ссылок, мы собираемся вернуть все это со всеми его частями или мы вернем очень простой POCO с дальнейшими запросами, необходимыми для заполнения пробелов. Общий this[int] не отвечает на этот вопрос, но:

GetBareBonesClient(int id);
GetClientAndProductDetail(int id);
GetClientAndContracts(int id);

На мой взгляд, довольно четко определены. В эти дни intellisense разработчик, кодирующий ваш репозиторий, будет знать, что ему нужно вызвать, чтобы получить то, что ему нужно. Как вы решаете, сколько из этих методов существует? Ну, вы смотрите на продукт, который вы на самом деле развиваете. Какие случаи у вас есть для получения данных... кто получает данные и почему они их получают? В большинстве случаев это легкие вопросы для ответа.

Одной из распространенных проблем, однако, является то, когда мы хотим разрешить пользователям "просматривать" данные в табличной форме. "Дайте мне" x "количество записей, отсортированных по полю" x ", с разбивкой по страницам... о, и я могу или не могу включать какой-то поиск в некоторый столбец". Этот тип кода - это то, чего вы действительно не хотите выполнять для каждого из ваших репозиториев. Таким образом, может быть случай для некоторого построения запросов котельной пластины в гипотетическом IRepositoryThatSupportsPagination. Я уверен, что вы можете подумать об этом лучше.

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

Ответ 3

[Отредактировано на основе ввода от MikeSW] Мое мнение (присоединение к Moo-Juice здесь) заключается в том, что вам нужно выбрать реализацию, которая вам больше всего подходит. Шаблон хранилища является хорошим (ответ Габриэля описывает хорошую реализацию), однако это может быть очень много работы, если оно реализовано в чистом виде. ORM автоматизируют большую работу ворчания.

Какой бы подход вы ни выбрали, вам понадобятся следующие компоненты:

  • Ваш бизнес-интерфейс - методы, которые потребуются вашим программистам на стороне клиента, такие как GetAllEmployees (критерии), UpdateEmployee (сотрудник Employee) и т.д. Если у вас есть архитектура клиент/сервер, это будет соответствовать вызовам службы с контрактами данных.

  • Ваша внутренняя логика, которая создает правильный вывод для удовлетворения ваших контрактов. Это будет слой, который составляет запросы или выполняет несколько обновлений базы данных, например, UpdateEmployee, возможно, придется проверять, существует ли сотрудник, который обновитель имеет разрешения для обновления, затем обновляет несколько таблиц и вставляет записи аудита или записи в очередь просмотра, Это потребует запроса и обновления и будет одной единицей работы.

  • Ваша архитектура доступа к данным, называемая вашей внутренней логикой. Здесь будет создан шаблон репозитория. Это необходимо, независимо от того, что вы используете, следующее:

3.1 Класс для реализации единицы работы. В шаблоне репозитория это имеет только Save() - но для этого требуется управление состоянием в памяти. Я предпочитаю использовать следующий интерфейс для реализации, реализованной в sql:

public interface ITransactionContext : IDisposable
{
    IDbTransaction BeginTransaction(IsolationLevel isolationLevel = IsolationLevel.ReadCommitted);

    void CommitTransaction();

    void RollbackTransaction();

    int ExecuteSqlCommand(string sql, params object[] parameters);

    IEnumerable<T> SqlQuery<T>(string sql, params object[] parameters);

    IEnumerable<T> SqlQuery<T>(string sql, object[] parameters, IDictionary<string, string> mappings);

    bool Exists(string sql, params object[] parameters);        
}

public interface ITransactionDbContext : ITransactionContext
{
    int SaveChanges();
}

Я использую EF, но у нас есть старый db, где нам нужно написать SQL, и это выглядит и работает так же, как EF DbContext. Обратите внимание на interace ITransactionDbContext, который добавляет SaveChanges() - единственное, что требуется ORM. Но если вы не делаете ORM, вам нужны другие.

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

public class TransactionContext : ITransactionContext
{
    protected IDbTransaction Transaction;
    protected IDbConnection Connection;
    protected readonly Func<IDbConnection> CreateConnection;

    public TransactionContext(Func<IDbConnection> createConnection)
    {
        this.CreateConnection = createConnection;
    }

    public virtual IDbConnection Open()
    {
        if (this.Connection == null)
        {
            this.Connection = this.CreateConnection();
        }

        if (this.Connection.State == ConnectionState.Closed)
        {
            this.Connection.Open();
        }

        return this.Connection;
    }


    public virtual IDbTransaction BeginTransaction(IsolationLevel isolationLevel)
    {
        Open();
        return this.Transaction ?? (this.Transaction = this.Connection.BeginTransaction(isolationLevel));
    }

    public virtual void CommitTransaction()
    {
        if (this.Transaction != null)
        {
            this.Transaction.Commit();
        }
        this.Transaction = null;
    }

    public virtual void RollbackTransaction()
    {
        if (this.Transaction != null)
        {
            this.Transaction.Rollback();
        }
        this.Transaction = null;
    }

    public virtual int ExecuteSqlCommand(string sql, params object[] parameters)
    {
        Open();
        using (var cmd = CreateCommand(sql, parameters))
        {
            return cmd.ExecuteNonQuery();
        }
    }

    public virtual IEnumerable<T> SqlQuery<T>(string sql, object[] parameters ) 
    {
        return SqlQuery<T>(sql, parameters, null);
    }

    public IEnumerable<T> SqlQuery<T>(string sql, object[] parameters, IDictionary<string, string> mappings) 
    {
        var list = new List<T>();
        var converter = new DataConverter();
        Open();
        using (var cmd = CreateCommand(sql, parameters))
        {
            var reader = cmd.ExecuteReader();
            if (reader == null)
            {
                return list;
            }

            var schemaTable = reader.GetSchemaTable();
            while (reader.Read())
            {
                var values = new object[reader.FieldCount];
                reader.GetValues(values);
                var item = converter.GetObject<T>(schemaTable, values, mappings);
                list.Add(item);
            }
        }
        return list;        }

    public virtual bool Exists(string sql, params object[] parameters)
    {
        return SqlQuery<object>(sql, parameters).Any();
    }

    protected virtual IDbCommand CreateCommand(string commandText = null, params object[] parameters)
    {
        var command = this.Connection.CreateCommand();
        if (this.Transaction != null)
        {
            command.Transaction = this.Transaction;
        }

        if (!string.IsNullOrEmpty(commandText))
        {
            command.CommandText = commandText;
        }

        if (parameters != null && parameters.Any())
        {
            foreach (var parameter in parameters)
            {
                command.Parameters.Add(parameter);
            }
        }
        return command;
    }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected void Dispose(bool disposing)
    {

        if (this.Connection != null)
        {
            this.Connection.Dispose();
        }

        this.Connection = null;
        this.Transaction = null;
    }
}

3,2. Затем вам нужно реализовать обновление на основе команды. Вот моя (упрощенная):

public class UpdateHelper
{
    private readonly ITransactionContext transactionContext;

    public UpdateHelper(ITransactionContext transactionContext)
    {
        this.transactionContext = transactionContext;
    }

    public UpdateResponse Update(UpdateRequest request)
    {
        this.transactionContext.BeginTransaction(IsolationLevel.RepeatableRead);
        var response = new UpdateResponse();
        foreach (var command in request.Commands)
        {
            try
            {
                response = command.PerformAction(transactionContext);
                if (response.Status != UpdateStatus.Success)
                {
                    this.transactionContext.RollbackTransaction();
                    return response;
                }
            }

            catch (Exception ex)
            {
                this.transactionContext.RollbackTransaction();
                return HandleException(command, ex);
            }
        }

        this.transactionContext.CommitTransaction();
        return response;
    }

    private UpdateResponse HandleException(Command command, Exception exception)
    {
        Logger.Log(exception);
        return new UpdateResponse { Status = UpdateStatus.Error, Message = exception.Message, LastCommand = command };
    }
}

Как вы видите, для этого требуется команда, которая будет выполнять действие (это шаблон команды). Реализация основной команды:

public class Command
{
    private readonly UpdateCommandType type;
    private readonly object data;
    private readonly IDbMapping mapping;

    public Command(UpdateCommandType type, object data, IDbMapping mapping)
    {
        this.type = type;
        this.data = data;
        this.mapping = mapping;
    }

    public UpdateResponse PerformAction(ITransactionContext context)
    {
        var commandBuilder = new CommandBuilder(mapping);
        var result = 0;
        switch (type)
        {
            case UpdateCommandType.Insert:
                result  = context.ExecuteSqlCommand(commandBuilder.InsertSql, commandBuilder.InsertParameters(data));
                break;
            case UpdateCommandType.Update:
                result = context.ExecuteSqlCommand(commandBuilder.UpdateSql, commandBuilder.UpdateParameters(data));
                break;
            case UpdateCommandType.Delete:
                result = context.ExecuteSqlCommand(commandBuilder.DeleteSql, commandBuilder.DeleteParameters(data));
                break;

        }
        return result == 0 ? new UpdateResponse { Status = UpdateStatus.Success } : new UpdateResponse { Status = UpdateStatus.Fail };
    }
}

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

public interface IDbMapping
{
    string TableName { get; }
    IEnumerable<string> Keys { get; }
    Dictionary<string, string> Mappings { get; }
    Type EntityType { get; }
    bool AutoGenerateIds { get; }
}

public class EmployeeMapping : IDbMapping
{
    public string TableName { get { return "Employee"; } }
    public IEnumerable<string> Keys { get { return new []{"EmployeeID"};} }
    public Dictionary<string, string> Mappings { get { return null; } } // indicates default mapping based on entity type } }
    public Type EntityType { get { return typeof (Employee); } }
    public bool AutoGenerateIds { get { return true; } }
}

3.4. Вам нужен построитель запросов. Это построит ваш запрос на основе ввода пользователем в sql. Например, вы можете искать сотрудников по имени, имени, отделу и дате присоединения. Вы можете реализовать интерфейс запроса следующим образом:

 public interface IEmployeeQuery {
     IEmployeeQuery ByLastName(string lastName);
     IEmployeeQuery ByFirstName(string firstName);
     IEmployeeQuery ByDepartment(string department);
     IEmployeeQuery ByJoinDate(Datetime joinDate);

 }

Это может быть конкретно реализовано классом, который либо строит запрос sql, либо запрос linq. Если вы собираетесь с sql, используйте string Statement и object[] Parameters. Тогда ваш логический уровень может написать такой код:

   public IEnumerable<Employee> QueryEmployees(EmployeeCriteria criteria) {
        var query = new EmployeeQuery(); 
        query.ByLastName(criteria.LastName);
        query.ByFirstName(criteria.FirstName); 
        //etc.
        using(var dbContext = new TransactionContext()){
            return dbContext.SqlQuery<Employee>(query.Statement, query.Parameters);
        }
   }

3,5. Для ваших объектов требуется построитель команд. Я предлагаю вам использовать общий командный сервер. Вы можете использовать класс SqlCommandBuilder или написать собственный генератор SQL. Я не предлагаю вам писать sql для каждой таблицы и каждого обновления. Это часть, которую будет очень сложно поддерживать. (Говоря из опыта. У нас был один, и мы не могли его поддерживать, в конце концов я написал генератор SQL.)

Примечание. Если у вас мало обновлений (т.е. ваше приложение в основном ориентировано на отображение), вы можете опустить это и просто писать свои обновления вручную, когда они вам понадобятся.

Вот общий конструктор (этот код НЕ ИСПЫТЫЙ, вам нужно его работать, как вам нужно):

public interface ICommandBuilder
{
    string InsertSql { get; }
    string UpdateSql { get; }
    string DeleteSql { get; }
    Dictionary<string, object> InsertParameters(object data);
    Dictionary<string, object> UpdateParameters(object data);
    Dictionary<string, object> DeleteParameters(object data);
}

public class CommandBuilder: ICommandBuilder
{
    private readonly IDbMapping mapping;
    private readonly Dictionary<string, object> fieldParameters;
    private readonly Dictionary<string, object> keyParameters; 

    public CommandBuilder(IDbMapping mapping)
    {
        this.mapping = mapping;
        fieldParameters = new Dictionary<string, object>();
        keyParameters = new Dictionary<string, object>();
        GenerateBaseSqlAndParams();
    }

    private void GenerateBaseSqlAndParams()
    {
        var updateSb = new StringBuilder();
        var insertSb = new StringBuilder();
        var whereClause = new StringBuilder(" WHERE ");
        updateSb.Append("Update " + mapping.TableName + " SET ");
        insertSb.Append("Insert Into " + mapping.TableName + " VALUES (");
        var properties = mapping.EntityType.GetProperties(); // if you have mappings, work that in
        foreach (var propertyInfo in properties)
        {
            var paramName = propertyInfo.Name;
            if (mapping.Keys.Contains(propertyInfo.Name, StringComparer.OrdinalIgnoreCase))
            {
                keyParameters.Add(paramName, null);
                if (!mapping.AutoGenerateIds)
                {
                    insertSb.Append(paramName + ", ");
                }
                whereClause.Append(paramName + " = @" + paramName);
            }
            updateSb.Append(propertyInfo.Name + " = @" + paramName + ", ");
            fieldParameters.Add(paramName, null);
        }
        updateSb.Remove(updateSb.Length - 2, 2); // remove the last ","
        insertSb.Remove(insertSb.Length - 2, 2);
        insertSb.Append(" )");
        this.InsertSql = insertSb.ToString();
        this.UpdateSql = updateSb.ToString() + whereClause;
        this.DeleteSql = "DELETE FROM " + mapping.TableName + whereClause;

    }

    public string InsertSql { get; private set; }

    public string UpdateSql { get; private set; }

    public string DeleteSql { get; private set; }

    public Dictionary<string, object> InsertParameters(object data)
    {
        PopulateParamValues(data);
        return mapping.AutoGenerateIds ? fieldParameters : keyParameters.Union(fieldParameters).ToDictionary(pair => pair.Key, pair => pair.Value);
    }

    public Dictionary<string, object> UpdateParameters(object data)
    {
        PopulateParamValues(data);
        return fieldParameters.Union(keyParameters).ToDictionary(pair => pair.Key, pair => pair.Value);
    }

    public Dictionary<string, object> DeleteParameters(object data)
    {
        PopulateParamValues(data);
        return keyParameters;
    }

    public void PopulateParamValues(object data)
    {
        var properties = mapping.EntityType.GetProperties(); // if you have mappings, work that in
        foreach (var propertyInfo in properties)
        {
            var paramName = propertyInfo.Name;
            if (keyParameters.ContainsKey(paramName))
            {
                keyParameters[paramName] = propertyInfo.GetValue(data);
            }
            if (fieldParameters.ContainsKey(paramName))
            {
                fieldParameters[paramName] = propertyInfo.GetValue(data);
            }
        }
    }
}

Пример использования обновления с помощью помощника обновления и построителя команд в логическом уровне для обновления:

public class Logic
{
    private readonly Func<ITransactionContext> createContext;
    private readonly Func<ITransactionContext, UpdateHelper> createHelper; 

    public Logic(Func<ITransactionContext> createContext, 
        Func<ITransactionContext, UpdateHelper> createHelper)
    {
        this.createContext = createContext;
        this.createHelper = createHelper;
    }

    public int UpdateEmployee(Employee employeeData)
    {
        using (var context = createContext())
        {
            var request = new UpdateRequest();
            request.Commands.Add(new Command(UpdateCommandType.Update, employeeData, new EmployeeMapping()));
            var helper = createHelper(context);
            var response = helper.Update(request);
            return response.TransactionId ?? 0;
        }
    }
}

ORM действительно поможет вам:

  • отображение данных
  • (вам не нужно это делать)
  • построение запросов - вы можете использовать встроенный Linq-to-Sql.

В целом, этот подход использует блок работы из шаблона репозитория, но вместо объекта репозитория и его методов добавления, обновления и удаления он использует класс UpdateHelper для выполнения обновлений на основе шаблона команды. Это позволяет напрямую записывать SQL без преобразования ORM.

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

Ответ 4

Да, вы можете легко написать элегантный слой DAL, основанный на универсальном, постоянном интерфейсе репозитория.

Вероятно, это, скорее всего, имело бы смехотворно плохую производительность.

В идеальном мире, где любая информация может быть извлечена из БД без каких-либо затрат, достаточно простого и общего хранилища. К сожалению, это не так - лучше иметь конкретные методы запросов, для каждой операции запроса, которую мы знаем, наша база данных может обрабатывать, чем иметь общий репозиторий, какие общие методы запросов, позволяющие использовать всевозможные сумасшедшие запросы из бизнес-уровня.

Edit

Я считаю, что вы ошибаетесь в отношении одного конкретного момента: чтобы избежать использования общей библиотеки ORM Mapping, вы не делаете ORM. Это не всегда так.

Если вы не показываете общие объекты типа массива в пользовательском интерфейсе (что также делает это обсуждение шаблона репозитория совершенно бесполезным), вы преобразуете реляционные данные в объекты домена. И это именно то, о чем идет ORM: тот факт, что вы не используете NHibernate, EF и LINQ to SQL, просто означает, что у вас будет гораздо больше работы.: -)

Итак, нет, использование Repository Pattern по-прежнему имеет смысл, независимо от использования автоматического инструмента ORM или нет.

Конечно, есть и другие варианты, такие как Активная запись. Это более простой шаблон, который смешивает объект домена с логикой доступа к данным (и использование инструмента ORM здесь также является необязательным).