Asp.Net MVC прокси-сервер для веб-клиента 1С: Предприятие 8.2

в 11:03, , рубрики: .net, 1c 8.2, 1c интеграция, 1c хостинг, , 1с предприятие 8, 1с:предприятие, ASP, asp.net mvc, asp.net mvc 3, iis, метки: , , , , , , , ,

Прокси-сервер для веб-клиента 1С: Предприятие 8.2 демонстрирует возможности подключения, управления содержимым, мониторинга и отладки html- и javascript-кодов, возвращаемых сервером 1С. Работу прокси-сервера можно наглядно посмотреть в Интернете по адресу: http://proxy.1csoftware.com

Введение

Одной из главных функциональных особенностей 1С: Предприятие 8.2 стала возможность получения доступа к данным 1С через Интернет. Но компания 1С делает это в своей традиционной манере, скрывая детали генерации веб-содержимого для браузеров и не обращая внимания на некоторые общепринятые стандарты. Программисту нужно относиться к процессу выдачи веб-содержимого, как к черному ящику, в котором по неизвестным законам происходит преобразование метаданных и данных в html-, json- и jscript-ответы от сервера. Прокси-сервер поможет вмешаться в процесс отображения данных и глубже разобраться с генерацией контента. Он будет находится между браузером и сервером 1С: Предприятие, перехватывать и перенаправлять запросы.

Структура и место прокси сервера между браузером и веб-сервером

Статья ссылается на технологии: Asp.Net MVC 3, .Net framework 4, IIS 7/7.5. Настоятельно рекомендуется запускать решение под IIS, а не в Visual Studio Development Server.
В качестве средства разработки была выбрана технология Asp.Net MVC 3 не случайно. Гибкость и наглядность предоставляемых средств позволяет быстро выполнить разработку и сэкономить на поддержке в будущем. Эту же задачу можно было бы решить на более низком уровне, например, через многопоточные HttpListener, но такое решение сопровождалось бы упомянутыми издержками. Правда, не исключено, что встретившись с нерешаемыми трудностями в будущем, придется переписать прокси-сервер на более низкоуровневых объектах. В случае с 1С: Предприятие такие трудности гарантированно есть всегда, и далеко не факт, что они были все выявлены и устранены. Речь о них пойдет ниже.

Пример опубликован в Интернете, и его можно посмотреть здесь: http://proxy.1csoftware.com

Проект Asp.Net MVC 3

Любой проект Asp.Net MVC начинается с проектирования структуры URL в методе RegisterRoutes, вызываемом в Application_Start из Gloval.asax. Для 1С: Предприятия URL строится так:

<домен>/<приложение>/<язык>/<путь-к-ресурсу>?<параметры-через-&>

Среди параметров одним из самых частых является sysver. Язык присутствует везде, кроме общего запроса к приложению. Соответственно этой структуре будет код, регистрирующий правила ProxyLanguage и Proxy:

        routes.MapRoute(
            "ProxyLanguage",                                              // Route name
            "{application}/{lang}/{*pathInfo}",                      // URL with parameters
            new { lang = System.Globalization.CultureInfo.CurrentUICulture.Name, controller = "Proxy", action = "Transfer", pathInfo = UrlParameter.Optional },
            new { lang = @"w{2}_w{2}|w{2}" }
        );

        routes.MapRoute(
            "Proxy",                                              // Route name
            "{application}/{*pathInfo}",                      // URL with parameters
            new { controller = "Proxy", action = "Transfer", pathInfo = UrlParameter.Optional }
        );
        
        routes.MapRoute(
            "Default", // Route name
            "{controller}/{action}/{id}", // URL with parameters
            new { controller = "Home", action = "Index", id = UrlParameter.Optional } // Parameter defaults
        );

Последняя команда была изначально в проекте и позволит открыть главную страницу с описанием примера, обратившись по адресу без пути. Для этого выделен отдельный контроллер Home, действие Index и вид Index с html-разметкой.
Исходя из кода, запросы для 1С будут перенаправлены на контроллер Proxy с действием Transfer. Контроллер лучше взять сразу асинхронный, наследовав от AsyncController, чтобы увеличить производительность. В этом случае действие Transfer будет состоять из двух методов:

public void TransferAsync(string pathInfo, string sysver)
public ActionResult TransferCompleted(HttpWebResponse response)

Так как приложение Application и язык Language предопределены, их целесообразно вынести в строковые свойства для доступа из любой части класса:

public string Language { get; set; }
public string Application { get; set; }

И инициализировать в перегруженном методе Initialize так:

        protected override void Initialize(System.Web.Routing.RequestContext requestContext)
        {
            base.Initialize(requestContext);

            if (requestContext.RouteData.Values.ContainsKey("lang"))
                Language = requestContext.RouteData.Values["lang"].ToString();

            if (requestContext.RouteData.Values.ContainsKey("application"))
                Application = requestContext.RouteData.Values["application"].ToString();
            else
                RedirectToAction("Index", "Home");
        }

Метод TransferAsync принимает запрос от клиента, инициализирует объект HttpWebRequest, передавая в него информацию из свойства Request контроллера о методе (GET или POST), заголовках браузера, куки, содержимом POST-запроса. Метод приведен полностью:

        public void TransferAsync(string pathInfo, string sysver)
        {
            AsyncManager.OutstandingOperations.Increment();

            ViewBag.SysVer = sysver;
            ViewBag.PathInfo = pathInfo;

            HttpWebRequest remoteRequest = (HttpWebRequest)HttpWebRequest.Create(new Uri("http://demo-ma.1c.ru/" + Application + (string.IsNullOrEmpty(Language)? "" : "/" + Language) + "/" + pathInfo + Request.Url.Query));
            remoteRequest.Method = Request.HttpMethod;
            remoteRequest.CookieContainer = new CookieContainer();
            if (Request.UrlReferrer != null)
                remoteRequest.Referer = Request.UrlReferrer.ToString();
            remoteRequest.UserAgent = Request.UserAgent;

            for (int i = 0; i < Request.Cookies.Count; i++) 
            {
                HttpCookie cookie = Request.Cookies.Get(i);

                Cookie newCookie = new Cookie();

                newCookie.Domain = remoteRequest.RequestUri.Host;

                newCookie.Expires  = cookie.Expires;
                newCookie.Name     = cookie.Name;
                newCookie.Path     = cookie.Path;
                newCookie.Secure   = cookie.Secure;
                newCookie.Value    = cookie.Value;

                remoteRequest.CookieContainer.Add(newCookie);
            }

            foreach(string key in Request.Headers)
            {
                if (key == "Connection")
                {
                    try
                    {
                        remoteRequest.Connection = Request.Headers.Get(key);
                    }
                    catch (Exception)
                    { }
                    continue;
                }
                if (key == "Accept")
                {
                    remoteRequest.Accept = Request.Headers.Get(key);
                    continue;
                }
                if (key == "Host")
                    continue;
                if (key == "User-Agent")
                    continue;
                if (key == "Referer")
                    continue;
                if (key == "Content-Length")
                    continue;
                if (key == "Content-Type")
                {
                    remoteRequest.ContentType = Request.Headers.Get(key);
                    continue;
                }
                remoteRequest.Headers.Add(key, Request.Headers.Get(key));
            }

            if (remoteRequest.Method == "POST")
            {
                using (var inputStream = remoteRequest.GetRequestStream())
                {
                    MemoryStream memoryStream = new MemoryStream();

                    byte[] buffer = new byte[255];
                    int bytesRead;
                    double totalBytesRead = 0;
                    Request.InputStream.Position = 0;
                    while ((bytesRead = Request.InputStream.Read(buffer, 0, buffer.Length)) > 0)
                    {
                        totalBytesRead += bytesRead;
                        memoryStream.Write(buffer, 0, bytesRead);
                    }

                    inputStream.Write(memoryStream.ToArray(), 0, (int)memoryStream.Length);
                    memoryStream.Close();
                }
            }

            remoteRequest.BeginGetResponse(result =>
                {
                    try
                    {
                        WebResponse response = remoteRequest.EndGetResponse(result);
                        AsyncManager.Parameters["response"] = (HttpWebResponse)response;
                    }
                    catch (WebException e)
                    {
                        AsyncManager.Parameters["response"] = (HttpWebResponse)e.Response;
                    }
                    AsyncManager.OutstandingOperations.Decrement();
                },
                null
            );
        }

Код метода TransformCompleted небольшой по размерам и представлен далее. В этом методе целесообразно отдельно получить поток ответа GetResponseStream() и сохранить его содержимое в переменную ViewBag.ResponseContent для повторного использования, так как несколько раз к этому потоку обратиться не получится.
Ответ от сервера может быть любой, необходимо определить свой ActionResult-наследованный класс ContentActionResult, и возвратить его. Он может содержать рисунки, html, json, jscript, текст и другие форматы.

        public ActionResult TransferCompleted(HttpWebResponse response)
        {
            using (var responseStream = response.GetResponseStream())
            {
                MemoryStream memoryStream = new MemoryStream();

                byte[] buffer = new byte[255];
                int bytesRead;
                double totalBytesRead = 0;
                while ((bytesRead = responseStream.Read(buffer, 0, buffer.Length)) > 0)
                {
                    totalBytesRead += bytesRead;
                    memoryStream.Write(buffer, 0, bytesRead);
                }
                ViewBag.ResponseContent = memoryStream.ToArray();
            }

            return new ContentActionResult() { RemoteResponse = response, FilePath = filePath, ResponseContent = ViewBag.ResponseContent };
        }

Класс ContentActionResult преобразует ответ от оригинального сервера 1С и возвратит клиенту куки, заголовки и тело ответа, а также код статуса.

    public class ContentActionResult : ActionResult
    {
        public HttpWebResponse RemoteResponse { get; set; }
        public string FilePath { get; set; }
        public byte[] ResponseContent { get; set; }

        public override void ExecuteResult(ControllerContext context)
        {
            var response = context.HttpContext.Response;
            response.ContentType = RemoteResponse.ContentType;
            response.Charset = RemoteResponse.CharacterSet;
            response.StatusCode = (int)RemoteResponse.StatusCode;

            for (int i = 0; i < RemoteResponse.Cookies.Count; i++)
            {
                Cookie cookie = RemoteResponse.Cookies[i];

                HttpCookie newCookie = new HttpCookie(cookie.Name);

                //newCookie.Domain = cookie.Domain;
                newCookie.Domain = context.HttpContext.Request.Url.Host;

                if (string.IsNullOrEmpty(newCookie.Domain))
                    newCookie.Domain = context.HttpContext.Request.Url.Host;
                //newCookie.Domain = "localhost";

                newCookie.Expires = cookie.Expires;
                newCookie.Name = cookie.Name;
                newCookie.Path = cookie.Path;
                newCookie.Secure = cookie.Secure;
                newCookie.Value = cookie.Value;

                response.SetCookie(newCookie);
            }

            foreach (string key in RemoteResponse.Headers.AllKeys)
            {
                response.AddHeader(key, RemoteResponse.Headers.Get(key));
            }

            response.BinaryWrite(ResponseContent);
        }
    }

Проблемы реализации

При разработке прокси-сервера было насколько проблем. Все они были связаны с невнимательностью компании 1С к стандартам веб-разработки. Если рассматривать пример статьи как unit-тест, то разработчикам компании 1С следует обратить внимание и зарегистрировать 2 проблемы:

Двоеточие в пути к ресурсу

Сервер от 1С допускает двоеточие в пути к ресурсу. Ответ от него может быть примерно следующим:
http://demo-ma.1c.ru/demoen/en_US/e1cib/pictureCollection/picture/0:dfa91944-c44c-403e-93b5-93d998359611?confver=01bdd81e-8d68-421d-a0e3-a381ab938613&t=false&w=48&h=48

Двоеточие является зарезервированным символом, и по стандарту rfc 3986 не допускается его использование в пути. Эта сложность приводит к невозможности принять запрос через Visual Studio Development Server и необходимости использовать IIS. Для IIS требуется дополнительная настройка в web.config:

<httpRuntime requestValidationMode="2.0" requestPathInvalidCharacters="<,>,*,&,,?" />

Настройка позволяет исключить двоеточие из недействительных символов пути.
Кто знает, может странное поведение тонкого клиента на IIS 7.x версии, отмеченное в сообществе 1С-разработчиков связано тоже с данной проблемой.

Неверный формат JSON

Некоторые ответы от 1С-сервера возвращают JSON-содержимое в виде:
{"root":{"cacheID":undefined, ...
Проблема возникает со значением undefined, которое по общепринятым стандартам должно быть заключено в кавычки. Значение может быть только строкой в двойных кавычках, числом, булевым значением: true или false, массивом в квадратных скобках или значением null.
Такое несоответствие приводит к ошибке: «Invalid JSON primitive: undefined», когда Asp.Net MVC пытается автоматически привести JSON к параметрам действия Transfer. Решается проблема исключением формата JSON из списка фабрик преобразований значений в Global.asax.

    void Application_Start(object sender, EventArgs e)
    {
        //Workaround error Invalid JSON primitive: undefined. when Post data contains {"root":{"cacheID":undefined, ...
        ValueProviderFactories.Factories.Remove(
                    ValueProviderFactories.Factories.OfType<JsonValueProviderFactory>().First());

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

Управление веб-страницами

Пример нового html-содержимого в окне инициализации

Прокси-сервер позволяет не только исследовать возвращаемые файлы сервером 1С, но и вмешаться в их генерацию. На рисунке видно простой пример, когда при инициализации показывается баннер в правом верхнем углу. Достигается это в методе TransferCompleted через отдельную сборку AgilityPack так:

            //Add new content
            if (ViewBag.PathInfo != null)
            {
                if (ViewBag.PathInfo == "mainform.html")
                {
                    HtmlDocument html = new HtmlDocument();
                    html.OptionFixNestedTags = true;
                    html.LoadHtml(Encoding.UTF8.GetString(ViewBag.ResponseContent, 0, ViewBag.ResponseContent.Length));

                    var res = html.DocumentNode.SelectSingleNode("//div[@id='preloader']");
                    HtmlNode node = html.CreateElement("img");
                    node.Attributes.Add("id", "1csoftware-powered");
                    node.Attributes.Add("style", "position:absolute;top:10px;right:10px;");
                    node.Attributes.Add("src", VirtualPathUtility.ToAbsolute("~/i/1csoftware.png"));
                    res.ChildNodes.Add(node);

                    ViewBag.ResponseContent = Encoding.UTF8.GetBytes(html.DocumentNode.OuterHtml);
                }
            }

За создание страницы загрузки отвечает файл mainform.html. Если в его div-раздел с именем preloader вставить какое-то содержимое, то содержимое появится в браузере при загрузке.
В более сложном варианте можно, например, исследовать работу форм и вмешаться в их логику, добавив свои элементы управления или обработчики, подключить jQuery. Можно поменять таблицу стилей и придать элементам свои цвета. Можно даже исправить самим ошибки Компании 1С, зная ее «оперативность» по борьбе с багами.

Заключение

Представленный в статье прокси-сервер находится между веб-браузером и сервером 1С: Предприятие 8.2. Перехватывает все запросы от браузера и передает их серверу. Таким образом, позволяет изучать передаваемые файлы и влиять на передаваемую информацию.
В качестве платформ разработки взяты .Net framework 4 и Asp.Net MVC 3. Решение построено через асинхронный контроллер для увеличения производительности. Кроме перенаправления запросов в прокси-сервер заложена логика обходных путей для 2х проблем: двоеточие в пути к ресурсу и некорректный формат JSON.
Решение обладает достаточной гибкостью и позволяет вмешаться в генерацию исходного кода html-, js- и других файлов.
В решении мало внимания уделялось логике работы 1С и взаимосвязи возвращаемых ответов от 1С-сервера. Это тема отдельной обширной статьи. Нереализованной и неисследованной осталась возможность работы по защищенному протоколу https. Работа тонкого клиента, соединенного через прокси-сервер также не исследовалась, хотя теоретически возможна.
Используя статью, можно написать прокси-сервер не только для узкой области 1С: Предприятие, но и для других своих решений. Случай с 1С: Предприятие более сложный, и кроме обычной трансформации запросов и откликов необходимо искать некоторые обходные пути, чтобы решение заработало.
Пример доступен в Интернете по адресу: http://proxy.1csoftware.com

Автор: Elisy

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js