Сообщений 0    Оценка 10        Оценить  
Система Orphus

Удобная реализация GET-параметров страницы в ASP.NET

Автор: Черняев Константин
Опубликовано: 16.11.2012
Исправлено: 10.12.2016
Версия текста: 1.0
Постановка проблемы
Идея
Реализация
Как использовать генератор
Результат
Исходники на GitHub

Постановка проблемы

При проектировании практически любого сайта появляется необходимость параметризовать страницу, передать ей параметры в адресной строке (URL). Однако инфраструктура ASP.NET дает возможность воспользоваться параметрами в коде страницы только с помощью объекта Request.QueryString, который имеет тип NameValueCollection. По сути это то же самое, что и Dictionary<string,string>. То есть программист имеет максимум возможность по строке, означающей имя параметра, получить строку, содержащую переданное значение. При таком подходе возникают две проблемы – одна в том, что имя параметра содержится в не проверяемой компилятором C# строке, а вторая в том, что значение параметра – всегда строка, то есть тип string. Но параметр, например, может содержать числовой идентификатор, или перечисление через запятую числовых идентификаторов, или даже сложный сериализованный объект. Обычно эти проблемы решаются созданием в классе страницы примерно таких свойств:

        protected int? Id
        {
            get
            {
                string val = this.Request.QueryString["Id"];
                if (val == null)
                    return null;
                int i;
                if (int.TryParse(val, out i))
                    return i;
                throw new FormatException(string.Format("Parameter \"Id\" is in wrong format. Passed value is [{0}]", val));
            }
        }

У такого подхода есть ряд серьезных недостатков:

Неплохим шагом к решению обозначенных проблем может быть такой подход:

Для каждого возможного типа параметра можно создать отдельный метод с сигнатурой <type> Get<type>FromQueryString(string name), где <type> - nullable-тип (то есть либо класс, либо Nullable<> от структуры). Определить этот метод, чтобы он в случае отсутствия нужного значения возвращал null, в случае ошибки формата генерировал исключение. Тогда использовать параметры становится значительно удобнее, но решается только половина описанных проблем, и то не полностью.

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

Идея

Идея предлагаемого подхода достаточно проста. Предлагается все передаваемые через QueryString GET-параметры страницы собрать как свойства в одном классе, своем для каждой страницы. А на странице сделать свойство этого класса (точнее, у родителя страницы). Свойства-параметры сделать строго типизированными, то есть, например, переданный числовой идентификатор должен быть типа int, переданный список идентификаторов – int[], а сложный сериализованный объект – соответствующего ему типа. Весь разбор и инициализацию провести на этапе Page_PreInit в родителе класса параметров, чтобы было как можно меньше повторяющегося кода. Если параметр обязателен, то в случае, когда он не передан, выводить пользователю простое и понятное сообщение об ошибке, а если необязателен – тип сделать nullable и инициализивать поле null’ом, когда значение не передано.

Кроме того, предлагаемая идея заключает в себе возможность в дополнение к непосредственно переданным параметрам иметь и объект, напрямую связанный с переданным значением. Например, если передается параметр «UserId=12», то можно создать дополнительное поле User (типа UserDTO например), автоматически инициализирующееся объектом, хранящим в себе пользователя с номером 12.

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

Реализация

Чтобы появился класс, отвечающий за параметры страницы, нужно в файле Page.Parameters.xml написать соответствующий странице xml-тег. Приведем схемный пример с разнообразными параметрами:

    <class name='<имя класса страницы>'>
        <f name='TenderId' type='int' canOmit='false' />
        <f name='CurrentTender' type='TenderDTO' canOmit='false'
            QSname='TenderId'summary='Объект аукциона' />

        <f name='LocateUserId' type='int' canOmit='true'/>

        <f name='SearchString' type='string' canOmit='true'  />
        <f name='RoleId' type='int[]' canOmit='true'/>
        <f name='TenderTypeId' type='int[]' canOmit='true'/>
        <f name='CreatedDateBegin' type='DateTime' canOmit='true' />
        <f name='CreatedDateEnd' type='DateTime' canOmit='true' />
        <f name='IsActive' type='bool' />
    </class>

Каждый подтег f (сокр. от field) соответствует своему QueryString-параметру. Имя генерируемого свойства в классе определяется атрибутом name, имя GET-параметра либо совпадает с ним, либо, если нужно задать отличающееся, берется из атрибута QSname.

Если тег помечен атрибутом canOmit='true', и в качестве типа (атрибут type, который означает имя .NET-типа) указана структура, то тип свойства делается <type>? – добавляется символ «?», то есть получается Nullable<>. Тогда в случае, когда значение в URL’е не передано, свойство принимает значение null. Если же указано canOmit='false', то, когда значение не передано, процедура инициализации генерирует исключение RequestParamsException – с сообщением об ошибке, что не передан обязательный параметр с указанием его имени. Разбор проходит на этапе Page_PreInit. В обработчике этого события страницы указанное исключение ловится, в этом случае пользователю показывается сообщение пойманного исключения, и обработка запроса прекращается (Response.End). Таким образом, в случае ошибки параметра никакой пользовательский код страницы выполнен не будет.

Напомним, что событие PreInit отсутствует у мастер-страницы и присутствует у control-ов. Но у них оно вызывается после страницы. Поэтому обработчик Page_PreInit – самая ранняя точка (из «обычных», то есть не берем в расчет общие события из Global.asax) в жизненном цикле страницы ASP.NET для исполнения пользовательского кода.

Класс параметров генерируется вложенным в класс страницы, то есть создаются файлы с именем вида MyPage.Parameters.cs и содержанием вида:

    public partial class MyPage
    {
        [Serializable]
        public partial class Parameters : PageParametersBaseInMyProject
        {
        …

В результате создание класса получается очень простым и удобным – надо только написать несколько строчек простейшего xml (в котором работает IntelliSense из xsd-схемы), указать, что страница унаследована от нужного класса, и в свойстве Ps (сокр. от Parameters) страницы появляются проинициализированные типизированные значения из QueryString с полной обработкой ошибок.

Реализация свойства Ps, хранящего в себе типизированные GET-параметры, находится в базовом классе страницы:

    public abstract class PageBase<TParameters> : Page
            where TParameters : PageParametersBase
    {
        protected TParameters Ps { get; privateset; }
        …

Инициализируется свойство Ps на этапе Page_PreInit в указанном классе PageBase. Таким образом, при создании страницы делать ничего не надо – нужно только записать xml-тег и указать родителя страницы.

За создание объекта Ps отвечает метод базового класса страницы CheckParsAndCreatePs, который определен так:

      protected
      void CheckParsAndCreatePs()
        {
            try
            {
                if (typeof (TParameters) == typeof (NoParams))
                    Ps = (TParameters) (object) new NoParams();
                else
                    Ps = (TParameters) Activator.CreateInstance(
                        typeof (TParameters), Context, Db);
            }
            catch (TargetInvocationException ex)
            {
                if (ex.InnerException is MyException)
                    thrownew PageDeniedException(ex.InnerException.Message);
                thrownew PageDeniedException(ex.ToString());
            }
            catch (MyException ex)
            {
                thrownew PageDeniedException(ex.Message);
            }
        }

Тип-параметр TParameters объявлен как наследник PageParametersBase. Предопределенный класс NoParams означает, что у страницы нет используемых параметров. Cобственно разбор QueryString происходит в конструкторе класса TParameters, которому передается HttpContext и объект доступа к БД Db. Выполняется метод CheckParsAndCreatePs так:

      protected
      virtual
      void Page_PreInit()
        {
            try
            {
                CheckParsAndCreatePs();
            }
            catch (PageDeniedException ex)
            {
                _logger.WarnException("", ex);
                SetResponseAsMyException(ex);
            }
        }

Таким образом, чтобы в коде страницы, например, Users можно было использовать QueryString-параметры через свойство Ps, достаточно ее объявить так:

      public
      partial
      class Users : PageBase<Users.Parameters>

или, если страница без параметров:

      public
      partial
      class CreateUser : PageBase<NoParams>

Реализация класса без параметров тривиальна:

      public
      class NoParams : PageParametersBase{}

Реализация базового класса для параметров PageParametersBase включает в себя все возможные методы получения значений и объектов из Request.QueryString. Эти методы можно разделить на три группы:

1. Для получения параметра тривиального типа, например bool:

      protected
      bool? GetBoolFromQueryString(HttpContext c, string name, bool canOmit)
    {
        string s = c.Request.QueryString[name];
        if (string.IsNullOrEmpty(s))
            if (!canOmit)
                throw RequestParamsException.CreateAsNoRequiredParam(name);
            elsereturnnull;
        bool val;
        if (!bool.TryParse(s, out val))
            throw RequestParamsException.CreateAsWrongBool(name, s);
        return val;
    }

2. Для получениямассива. Например, если странице передается строка «1,2,3», а значение параметра должно стать равным new[]{1,2,3}, нужно использовать такой метод:

      protected
      int[] GetIntArFromQueryString(HttpContext c, string name,
        bool canOmit)
    {
        string s = c.Request.QueryString[name];
        if (string.IsNullOrEmpty(s))
            if (!canOmit)
                throw RequestParamsException.CreateAsNoRequiredParam(name);
            elsereturnnull;

        string[] ss = s.Split(SeparatorInArray,
            StringSplitOptions.RemoveEmptyEntries);
        int[] rv = ss.Select(x =>
                                {
                                    int val;
                                    if (int.TryParse(x, out val))
                                        return val;
                                    throw RequestParamsException
                                        .CreateAsWrongIntAr(name, s);
                                }).ToArray();
        return rv;
    }

3. Для получения бизнес-объектов. Например, если передался параметр «UserId=12», и свойство User должно стать объектом, соотвествующим пользователю с идентификатором 12, нужно использовать такой метод:

      protected UserDTO GetUserDTOFromQueryString(HttpContext c,
string name, Db db, bool canOmit)
    {
        int? id = GetIntFromQueryString(c, name, canOmit);
        if (id == null)
            returnnull;
        UserDTO o = db.FindUserDTOById(id.Value);
        if (o == null)
            throw RequestParamsException.CreateAsWrongUserId(name, id.Value);
        return o;
    }

Такие методы должны быть реализованы в классе, относящемся к конкретному проекту сайта, поскольку в проекте с базовым классом PageParametersBase нет бизнес-классов (так как он слишком общий), и они в каждом проекте сайта могут быть свои. Пример объявления:

      public
      abstract
      class PageParametersBaseInMyProject : PageParametersBase

Сами классы, хранящие QueryString-параметры, представляют собой практически DTO, то есть содержащие в себе только свойства без поведения, например, так:

      public
      int BidId { get; set; }
            public BidDTO CurrentBid { get; set; }

Инициализируются свойства, как уже было указано, в конструкторе посредством указанных групп методов таким образом:

      void Set()
            {
                BidId = GetIntFromQueryString(_c, "BidId", false).Value;
                ResetObjects();
            }
            publicvoid ResetObjects()
            {
                CurrentBid = GetBidDTOFromQueryString(_c, "BidId", _db, false);
                FillAddon();
            }
            partialvoid FillAddon();

Здесь _c и _db – переданные в конструктор объекты HttpContext и Db. Метод ResetObjects бывает нужен, чтобы переинициализировать заново объекты из идентификаторов, если стало известно, что они поменялись (например, на странице редактирования пользователя после его сохранения). Метод FillAddon бывает полезно определить, когда нужно как-то доинициализировать объекты, или если необходимо добавить свойство, напрямую не зависящее от QueryString.

Приведу примеры, когда удобно использовать метод FillAddon.

1. Предположим, на портале форума есть страница, на которой производится поиск сообщений, в частности, по автору. Тогда весьма вероятно, что идентификатор пользователя (или список идентификаторов) нужно вывести в параметры страницы, чтобы можно было передавать URL с заполненным фильтром. Предположим также, что кроме явно заданных идентификаторов пользователей (назовем параметр UserIds) можно задавать дополнительные условия фильтра по автору – роли пользователя, его группы, строка поиска (маска на ФИО или логин), рейтинг, компания-работодатель.

Тогда удобно сделать следующее. В дополнительной partial-части класса параметров объявляем новое поле int[]FilterByUsers и реализуем метод FillAddon, инициализируя в нем FilterByUsers на основе всех переданных перечисленных выше параметров:

    public partial class FindPosts : PageBase<FindPosts.Parameters>
    {
        partial class Parameters
        {
            public int[] FilterByUsers { getprivate set; }
            partial void FillAddon()
            {
                UserDTOFinder finder = new UserDTOFinder { ... };
                if(finder.IsEmpty)
                    FilterByUsers = UserIds;
                else
                {
                    FilterByUsers = _db.FindUserDTOs(f)
                        .Select(u => u.UserId).ToArray();
                    if (UserIds != null && UserIds.Length > 0)
                        FilterByUsers = FilterByUsers.Intersect(UserIds)
                            .ToArray();
                }
            }
        }
        …

И в результате получаем, что на странице достаточно просто использовать Ps.FilterByUsers без Ps.UserId, а тот факт, что поиск авторов тоже выполняется сложным поиском, никак не влияет на код страницы.

2. Предположим, есть страница с двумя параметрами, и есть условие, что должен быть задан хотя бы один из них – любой, но хотя бы один. Тогда в xml-описании оба параметра нужно пометить как необязательные (canOmit=’false’), но определить FillAddon:

    public partial class PageName : PageBase<PageName.Parameters>
    {
        partial class Parameters
        {
            partial void FillAddon()
            {
                if(this.Parameter1==null && this.Parameter2==null)
                    RequestParamsException.Throw("One of Parameter1 or Parameter2 is required");
            }
        }
        …

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

В рамках предлагаемого подхода удобно будет разрешить эту ситуацию следующим образом. В параметры страницы в xml-описании записываем все параметры всех команд, помечая их как необязательные (canOmit=’false’), и для проверки правильности переданных аргументов определяем метод FillAddon, например, так:

      public partial class PageName : PageBase<PageName.Parameters>
{
    partial class Parameters
    {
        partial void FillAddon()
        {
            if(this.Command=="CommandA")
            {
                if(this.Parameter1==null)
                    RequestParamsException.Throw("CommandA: Parameter1 is required ");
                if(this.Parameter2.HasValue && (this.Parameter2>10 
|| this.Parameter2<=0))
                    RequestParamsException.Throw("CommandA: Parameter2 must be in interval [1,10] or omitted");
            }
            elseif(this.Command=="CommandB")
            {
                if(this.Parameter1.HasValue && (this.Parameter1>20 
|| this.Parameter1<=10))
                    RequestParamsException.Throw("CommandB: Parameter1 must be in interval [11,20] or omitted");
                if(this.Parameter2==null)
                    RequestParamsException.Throw("CommandB: Parameter2 is required ");
            }
        }
    }
    …

Как использовать генератор

Если имеется несколько проектов ASP.NET, то удобно делать так.

1. Сам генератор Page.Parameters.tt кладем в общий репозитарий компании (вместе с ним схему Page.Parameters.xsd, и вспомогательный файл Common.tt), назовем его My.Common.

2. В репозитарии с проектом сайта, где планируется использовать генератор, делаем My.Common субмодулем.

3. В проекте сайта создаем файл Include_Page.Parameters.tt с таким простым содержанием:

<#@ includefile="../../My.Common/Page.Parameters.tt" #>

Использовать генератор нужно в каждом репозитарии, где есть проект сайта. Важно, чтобы генератор (или его прокси Include_Page.Parameters.tt) находился в одном проекте с сайтом, потому что он генерирует partial-части классов страниц сайта, и поэтому не может быть в другом проекте.

4. Чтобы в xml-описании работал intellisense, достаточно в проекте сайта положить не сам файл xsd-схемы Page.Parameters.xsd, а ссылку на него (он лежит в My.Common):


5. В одной папке с прокси для генератора Include_Page.Parameters.tt создаем файл-описание Page.Parameters.xml, нацеливаем на него xsd-схему и начинаем заполнять.

6. Запускаем генерацию. Появляются классы, отвечающие за реализацию GET-параметров.

7. Используем классы параметров в коде страниц.

Если же проект один, то, конечно, проще положить файлы Page.Parameters.tt, Common.tt и Page.Parameters.xsd в проект с сайтом.

Результат

Применение описанного подхода дает следующие плюсы:

Исходники на GitHub

Небольшой показательный пример проекта можно посмотреть здесь. Проект My.Common, содержащий генератор Page.Parameters.tt лежит тут, а сам генератор - тут (необходимы файл-хелпер и xsd-схема).


Любой из материалов, опубликованных на этом сервере, не может быть воспроизведен в какой бы то ни было форме и какими бы то ни было средствами без письменного разрешения владельцев авторских прав.
    Сообщений 0    Оценка 10        Оценить