Прокси-сервер для веб-клиента 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",

     "{application}/{lang}/{*pathInfo}",

     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",

     "{application}/{*pathInfo}",

     new { controller = "Proxy", action = "Transfer", pathInfo = UrlParameter.Optional }

);

routes.MapRoute(

     "Default",

     "{controller}/{action}/{id}",

     new { controller = "Home", action = "Index", id = UrlParameter.Optional }

);

Последняя команда была изначально в проекте и позволит открыть главную страницу с описанием примера, обратившись по адресу без пути. Для этого выделен отдельный контроллер 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();

}

Метод 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 = context.HttpContext.Request.Url.Host;

if (string.IsNullOrEmpty(newCookie.Domain))

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

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 requestPathInvalidCharacters="&lt;,&gt;,*,&amp;,\,?" />

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

Кто знает, может странное поведение тонкого клиента на 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)

{

ValueProviderFactories.Factories.Remove( ValueProviderFactories.Factories.OfType<JsonValueProviderFactory>().First());

...

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

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

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

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

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

Скачать исходный код AspNetProxy.zip (1.9 Mb)