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