Это продолжение статьи «Кроссплатформенное использование классов .Net из неуправляемого кода. Или аналог IDispatch на Linux».

Там мы получили возможность использования классов .Net в неуправляемом приложении. Теперь воспользуемся «Создание компонент с использованием технологии Native API».

Итак, начнем.

Для начала определим используемые классы:

// Определение функции для единообразного вызова методов
	typedef bool(*CallAsFunc) (void * , tVariant* , tVariant* , const long);

	// Вспомогательный класс для хранения и единообразного вызова
	// Что бы не городить кучу switch 
	class MethodsArray
	{
	public:
		// Имя метода на кириллице
		//1С ники понимают только на нём
		wstring MethodName;
         // Ссылка на метод
		CallAsFunc  Method;
		//Количество параметров
		long    ParamCount;
		//Признак возвращаемого значения
		bool    HasRetValue;
       // Метод инициализации класса
		void Init(wstring MethodName, CallAsFunc  Method, long    ParamCount, bool    HasRetValue);
	};

	
	///////////////////////////////////////////////////////////////////////////////
	// class CAddInNative
	class BaseNetObjectToNative : public IComponentBase
	{
	public:
	
		static BaseNetObjectToNative* pCurrentObject;

		// Ссылка на массив методов
		MethodsArray* pMethodsArray;
		// Размер массива параметров
		int SizeArray;
		// Имя класса для 1С
		wstring ClassName;
		// Имя текущего вызываемого метода
		wstring MethodName;
		// Сслка на объект для вызова методв класса .Net
		ManagedDomainLoader* NetProvider;

		// Строковое представление объекта .Net для передачи в параметрах методов.
		wstring RefNetObject;
		// Индекс в массиве объектов на стороне .Net 
		long  IdNetObject;

		// Текущий найденный метод ВК
		MethodsArray* CurrentElem;
		// Нужен для нахождения количества параметрах м методах с переменных их количествои и перегрузках
		long LastParamsIndex;



class LoaderCLR :public  BaseNetObjectToNative
	{
	public:

		// Массив методов из двух элементов
		MethodsArray MethodsArray[2];
		LoaderCLR();
		virtual ~LoaderCLR();

		virtual bool ADDIN_API Init(void*  pConnection);
		virtual bool ADDIN_API setMemManager(void* memManager);

		// Метод для перичной инициализации .Net
		static bool CreateDamain(void* Self, tVariant* pvarRetValue, tVariant* paParams, const long lSizeArray);
		// Метод для предотвращения выгрузки DLL
		static bool LoadDLL(void* Self, tVariant* pvarRetValue, tVariant* paParams, const long lSizeArray);
		
       
		
       // Метод для выделения памяти ссылка на который отправляется в .Net
		static void* GetMem(long ByteCount);
		// Метод для сообщения об ссылка на который отправляется в .Net
		static void  AddError(const wchar_t* ErrorInfo);
	};

	class NetObjectToNative :public BaseNetObjectToNative
	{
	public:
		MethodsArray MethodsArray[3];
		NetObjectToNative();
	

		
		// Установка ссылки для передачи в параметрах и получение индекса объекта в сиске объектов .Net 
		static bool SetNetObjectRef(void* Self, tVariant* pvarRetValue, tVariant* paParams, const long lSizeArray);
		// Получение ссылки для передачи в параметрах
		static bool GetNetObjectRef(void* Self, tVariant* pvarRetValue, tVariant* paParams, const long lSizeArray);



	
	};


Теперь перейдем к реализации. Остановлюсь только на основных

	//---------------------------------------------------------------------------//
	long BaseNetObjectToNative::FindProp(const WCHAR_T* wsPropName)
	{
		// Свойства есть только у .Net классов.
		if (NetProvider == nullptr) return -1;
		long plPropNum = 1;

		// Устанавливаем MethodName для вызова GetPropVal или SetPropVal
		// и не ищем их на стороне .Net
		MethodName = wsPropName;

		return plPropNum;
	}

	//---------------------------------------------------------------------------//
	const WCHAR_T* BaseNetObjectToNative::GetPropName(long lPropNum, long lPropAlias)
	{

		// Можно вернуть MethodName но лень.
		return 0;
	}
	//---------------------------------------------------------------------------//
	bool BaseNetObjectToNative::GetPropVal(const long lPropNum, tVariant* pvarPropVal)
	{
		// Установим на всякий случай для выделения памяти текущий объект
		SetMemoryManager();
		// Свойства есть только у .Net классов. Их и вызываем используя соххраненное имя свойства 
		//MethodName
		return NetProvider->pGetPropVal(IdNetObject,MethodName.c_str(), pvarPropVal);

	}
	//---------------------------------------------------------------------------//
	bool BaseNetObjectToNative::SetPropVal(const long lPropNum, tVariant* varPropVal)
	{
		// Аналогично GetPropVal
		return NetProvider->pSetPropVal(IdNetObject, MethodName.c_str(), varPropVal);
	}
	//---------------------------------------------------------------------------//
	bool BaseNetObjectToNative::IsPropReadable(const long lPropNum)
	{
		// Не будем лезть в .Net. 
		// Подразумевается, что автор сам следит за тем, что делает
		// Если свойство нечитаемо будет выдана ошибка. Но к сожалению 1С эту ошибку не обрабатывает.
			return true;
		
	}
	//---------------------------------------------------------------------------//
	bool BaseNetObjectToNative::IsPropWritable(const long lPropNum)
	{
		// Аналогично IsPropReadable
			return true;
		
	}
	//---------------------------------------------------------------------------//
	long BaseNetObjectToNative::GetNMethods()
	{
		// Не знаем сколько методов
		return 0;
	}
	//---------------------------------------------------------------------------//
	long BaseNetObjectToNative::FindMethod(const WCHAR_T* wsMethodName)
	{
	// Запомним имя метода
		MethodName = wsMethodName;
   // Сначала посмотрим есть ли метод в компоненте
		long res= findMethod(MethodName);
		if (res==0 && NetProvider == nullptr) return -1;

		// Так как методы .Net используют перегрузку и используя params 
		//можно указать параметр метода, принимающий переменное количество аргументов.
		//LastParamsIndex нужен для нахождения количества использумых параметров в вызываемом методе
		LastParamsIndex = -1;
		MethodName = wsMethodName;
		return res;
	
	}
	//---------------------------------------------------------------------------//
	const WCHAR_T* BaseNetObjectToNative::GetMethodName(const long lMethodNum,
		const long lMethodAlias)
	{

		return 0;//MethodName.c_str();
	}
	//---------------------------------------------------------------------------//
	long BaseNetObjectToNative::GetNParams(const long lMethodNum)
	{
		// Здесь  возвращаем количество параметров
		//Для парамс ограничеваем 16 параметрами
		if (lMethodNum==0)
			return  NetProvider->pGetNParams(IdNetObject, MethodName.c_str());
		else
			return CurrentElem->ParamCount;
		
	
	}
	//---------------------------------------------------------------------------//
	bool BaseNetObjectToNative::GetParamDefValue(const long lMethodNum, const long lParamNum,
		tVariant *pvarParamDefValue)
	{

		// В этом методе идет запрос значения по умолчанию
		//Для нас это означает испльзуемое количество параметров в вызываемом методе
		if (LastParamsIndex == -1) LastParamsIndex = lParamNum;

		pvarParamDefValue->vt = VTYPE_I4;
		pvarParamDefValue->lVal = 0;






		return true;
	}
	//---------------------------------------------------------------------------//
	bool BaseNetObjectToNative::HasRetVal(const long lMethodNum)
	{
		if (lMethodNum > 0) return CurrentElem->HasRetValue;
		// Для .Net классов считаем что все возвращают значения. Даже если нет, то вернем null
		return true;
	}
	//---------------------------------------------------------------------------//
	bool BaseNetObjectToNative::CallAsProc(const long lMethodNum,
		tVariant* paParams, const long lSizeArray)
	{
		
		// Вызываем метод. Но 1С вызывает его не по тому как он вызван, а зависит от HasRetVal
		if (lMethodNum==0)
		{
			SetMemoryManager();
			if (LastParamsIndex == -1) LastParamsIndex = lSizeArray;
			return NetProvider->pCallAsFunc(IdNetObject, MethodName.c_str(), 0, paParams, LastParamsIndex);
		}
		
		return CurrentElem->Method(this, 0, paParams, lSizeArray);

		
	}
	//---------------------------------------------------------------------------//
	bool BaseNetObjectToNative::CallAsFunc(const long lMethodNum,
		tVariant* pvarRetValue, tVariant* paParams, const long lSizeArray)
	{
		// Вызываем метод. Но 1С вызывает его не по тому как он вызван, а зависит от HasRetVal
		// Даже если он вызван как процедура 1С вызывает его как функцию.
		pvarRetValue->vt = VTYPE_NULL;
		pvarRetValue->lVal = 0;

		if (lMethodNum == 0)
		{
			SetMemoryManager();
			if (LastParamsIndex == -1) LastParamsIndex = lSizeArray;
			return NetProvider->pCallAsFunc(IdNetObject, MethodName.c_str(), pvarRetValue, paParams, LastParamsIndex);
		}

		return CurrentElem->Method(this, pvarRetValue, paParams, lSizeArray);
	}
	//----------------------------




Это основные методы. Теперь можно перейти к вызовам кода из 1С.

Сначала объявим переменные и вспомогательные функции.

Перем   Врап,СсылкаНаДомен;

Функция СоздатьОбъектПоСсылке(Ссылка)
// Создаем объект по ссылке полученной из методов .Net классов
//Физически это строка ёЁ<Ьъ>№_%)Э?&2 содержащее 12 символов для отделения их от других строк
//и индекс в спике исполуемых объектов на стороне .Net
    рез = Новый("AddIn.NetObjectToNative.NetObjectToNative");
    рез.УстановитьСсылку(Ссылка);    
    возврат  рез
КонецФункции // СоздатьОбъектПоСсылке()

Функция Ъ(Ссылка)
	
	// Зосдадим объект ВК
    рез = Новый("AddIn.NetObjectToNative.NetObjectToNative");
	// И установим ссылку
    рез.УстановитьСсылку(Ссылка);    
    возврат  рез
КонецФункции // СоздатьОбъектПоСсылке()

// Сокращенное использование метода ВК Новый
// Создает объект по строковому представлению типа или по ссылке на тип
Функция ъНовый(стр)
	возврат ъ(Врап.Новый(стр));
КонецФункции

// Сокращенное использование метода ВК ПолучитьТип
// Создает получает тип по строковому представлению типа 
Функция ъТип(стр)
	  возврат ъ(Врап.ПолучитьТип(стр));
КонецФункции



Процедура ПриОткрытии() 
	
	// Установим отчет рядом с  AddInNetObjectToNative.dll
	// NetObjectToNative.dll
	// и библиотеками  Microsoft.NETCore.App\1.0.0\	
       // Так как нужны 32 разрядные так как клиент 1С 32 разрядный
       // Скачать можно здесь https://github.com/dotnet/cli
	// На сервере можно использовать 64 разрядную Core Clr
   Файл=Новый Файл(ЭтотОбъект.ИспользуемоеИмяФайла);  
   КаталогОтчета=Файл.Путь;	
 
    ИмяФайла=КаталогОтчета+"\AddInNetObjectToNative.dll";
	
	ПодключитьВнешнююКомпоненту(ИмяФайла, "NetObjectToNative",ТипВнешнейКомпоненты.Native); 
	Врап = Новый("AddIn.NetObjectToNative.LoaderCLR");
	CoreClrDir=КаталогОтчета+"\bin\";
	ДиректорияNetObjectToNative=КаталогОтчета;
	
	СсылкаНаДомен=Врап.СоздатьОбертку(CoreClrDir,ДиректорияNetObjectToNative,"");
    Врап.ЗагрузитьDLL(ИмяФайла);
КонецПроцедуры



А теперь потирая руки можно создать код для использования .Net в 1С. И вот этот волнительный момент настал!

Процедура ТестStringBuilderНажатие(Элемент)
	СБ=ъ(Врап.Новый("System.Text.StringBuilder","Первая Строка"));
    CultureInfo=ъТип("System.Globalization.CultureInfo");
    
    CultureInfoES=ъ(Врап.Новый(CultureInfo.ПолучитьСсылку(),"es-ES"));
     
   
    Сообщить(СБ.Capacity);
    Сообщить(СБ.ПолучитьСсылку());
    
    InvariantCulture=ъ(CultureInfo.InvariantCulture);
    
    // К сожалению 1С вызывает метод имеющий возвращаемое значение как функцию даже если вызов идет как процедура
    //Нужно очистить ссылку в списке объектов
    ссылка=Сб.Append("Новая Строка"); Врап.ОчиститьСсылку(ссылка);
    ссылка=Сб.AppendLine();   Врап.ОчиститьСсылку(ссылка);
    ссылка=Сб.Append("Вторая Строка"); Врап.ОчиститьСсылку(ссылка);
    ссылка=Сб.AppendLine();     Врап.ОчиститьСсылку(ссылка);
    
    ссылка=Сб.AppendFormat("AppendFormat {0}, {1}, {2}, {3}, {4},", "Строка", 21, 45.89, ТекущаяДата(),истина );   Врап.ОчиститьСсылку(ссылка);
    ссылка=Сб.AppendLine(); Врап.ОчиститьСсылку(ссылка);
    
    // Так как в параметрах можно передавать только простые типы закодирум ссылку на объект в строку
    ссылка=Сб.AppendFormat(CultureInfoES.ПолучитьСсылку(),"AppendFormat {0}, {1}, {2}, {3}, {4},", "Строка", 21, 45.89, ТекущаяДата(),истина );  Врап.ОчиститьСсылку(ссылка);
    ссылка=Сб.AppendLine(); Врап.ОчиститьСсылку(ссылка);
    
    ссылка=Сб.AppendFormat(InvariantCulture.ПолучитьСсылку(),"AppendFormat {0}, {1}, {2}, {3}, {4},", "Строка", 21, 45.89, ТекущаяДата(),истина ); 
        
    Сообщить(СБ.ToString());
    Сообщить("Ёмкостъ ="+СБ.Capacity);
   // Увеличим емкость
    СБ.Capacity=СБ.Capacity+40;
    Сообщить("Новая Ёмкостъ ="+СБ.Capacity);
    
    // Очистка ссылок СБ и  СultureInfo осуществляется внутри ВК
КонецПроцедуры



Сразу отмечу, то что 1С зачем то хочет установить свойство InvariantCulture хотя её об этом никто не просит.
Если я вызову метод Сб.Append(«Новая Строка»); как процедуру, то 1С все равно вызывает как функцию и на стороне .Net сохраняется в списке, из которого нужно освобождать.

Теперь посмотрим более сложный пример.

// Создадим HttpClient и вызовем Get запрос используя сжатие трафика 
// На данный момент я не нашел как получить загруженные сборки или как загрузить сборку по имени файла
// Поэтому загружаем по полному имени
HttpClient=ъТип("System.Net.Http.HttpClient, System.Net.Http, Version=4.0.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a");
HttpClientHandler = ъТип("System.Net.Http.HttpClientHandler, System.Net.Http, Version=4.0.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a");
DecompressionMethods= ъТип("System.Net.DecompressionMethods, System.Net.Primitives, Version=4.0.11.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a");


handler = ъНовый(HttpClientHandler.ПолучитьСсылку());
// Можно использовать и так. Только Не соберем ссылки
//handler.AutomaticDecompression=Врап.OR(DecompressionMethods.GZip,DecompressionMethods.Deflate);

ссылкаGZip=DecompressionMethods.GZip;
ссылкаDeflate=DecompressionMethods.Deflate;

// В 1С нет бинарных операция. Для этого на стороне .Net есть функция
handler.AutomaticDecompression=Врап.OR(ссылкаGZip,ссылкаDeflate);
Врап.ОчиститьСсылку(ссылкаGZip);   Врап.ОчиститьСсылку(ссылкаDeflate);

 Клиент = ъ(Врап.Новый(HttpClient.ПолучитьСсылку(),handler.ПолучитьСсылку()));

uriSources ="https://msdn.microsoft.com/en-us/library/system.net.decompressionmethods(v=vs.110).aspx";

 Стр=ъ(Клиент.GetStringAsync(uriSources)).Result;
 Сообщить(СтрДлина(стр));



Ух ты, и это работает!

Можно загружать сторонние библиотеки.

Тестовый=ъТип("TestDllForCoreClr.Тестовый, TestDllForCoreClr, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null");
Тест=ъ(Врап.Новый(Тестовый.ПолучитьСсылку()," Свойство из Конструктора"));
Сообщить(Тестовый.Поле);
Тестовый.Поле="Установка из 1С";
Сообщить(Тестовый.Поле);

Сообщить(Тест.СвойствоОбъекта);

Тест.СвойствоОбъекта=("Установлено Свойство из 1С");
Сообщить(Тест.СвойствоОбъекта);
Сообщить(Тест.ПолучитьСтроку());


Теперь перейдем к более грустному.
В предыдущей статье был тест скорости время вызова кторого составляло оболее 300 000 вызовов в секунду.

Проведем аналогичный тест на 1С:

Функция ПолучитьЧисло(зн)

возврат зн;	

КонецФункции // ()
 
Процедура ТестСкорости2Нажатие(Элемент)
	// Вставить содержимое обработчика.
	КоличествоИтераций=200000;
Тестовый=ъТип("TestDllForCoreClr.Тестовый, TestDllForCoreClr, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null");
Тест=ъ(Врап.Новый(Тестовый.ПолучитьСсылку()," Свойство из Конструктора"));

	stopWatch = ъНовый("System.Diagnostics.Stopwatch,System.Runtime.Extensions, Version=4.0.11.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a");
	
	стр="";
	 Тест.ПолучитьЧисло(1);
	 
	НачВремя=ТекущаяДата();
	stopWatch.Start();
	
	 
	Для сч=1 по КоличествоИтераций Цикл
		Тест.ПолучитьЧисло(сч);

	КонецЦикла;
	
	stopWatch.Stop();
	
	ВремяВыполнения=ТекущаяДата()-НачВремя;
	Сообщить("ПолучитьЧисло ="+ВремяВыполнения);
	ВывестиВремя(stopWatch);
	
	
	  НачВремя=ТекущаяДата();
	stopWatch.Restart();
	
	 
	Для сч=1 по КоличествоИтераций Цикл
		ПолучитьЧисло(сч);

	КонецЦикла;
	
	stopWatch.Stop();
	
	ВремяВыполнения=ТекущаяДата()-НачВремя;
	Сообщить("ПолучитьЧисло ="+ВремяВыполнения);
	ВывестиВремя(stopWatch);

КонецПроцедуры



00:00:09.93 Для .Net
00:00:01.20 Для 1С

То есть скорость вызова уменьшилась до менее чем 20 000 вызовов в секунду.
Но и этого достаточно, так обычно вызываются более тяжелые методы.

Добавил поддержку объектов с поддержкой IDynamicMetaObjectProvider

Для теста создад ExpandoObject

  public object ПолучитьExpandoObject()
        {

            dynamic res = new ExpandoObject();
            res.Имя = "Тест ExpandoObject";
            res.Число = 456;
            res.ВСтроку = (Func<string>)(() => res.Имя);
            res.Сумма = (Func<int, int, int>)((x, y) => x + y);

            return res;
        }



И наследника DynamicObject

class TestDynamicObject : DynamicObject
    {

        public override bool TrySetMember(SetMemberBinder binder, object value)
        {

            return true;
        }
        // получение свойства
        public override bool TryGetMember(GetMemberBinder binder, out object result)
        {
            result = binder.Name;
            return true;
        }
        // вызов метода
        public override bool TryInvokeMember(InvokeMemberBinder binder, object[] args, out object result)
        {
            var res = new StringBuilder("{0}(");
            var param = new object[args.Length + 1];
            param[0] = binder.Name;
            if (args.Length > 0)
            {
                Array.Copy(args, 0, param, 1, args.Length);
                for (int i = 0; i < args.Length; i++)
                {
                    res.AppendFormat("{{{0}}},", i + 1);

                }

                res.Remove(res.Length - 1, 1);

            }
            res.Append(")");

            result = String.Format(res.ToString(), param);
            return true;


        }
    }



Теперь можно вызвать на 1С

Тестовый=ъТип("TestDllForCoreClr.Тестовый, TestDllForCoreClr, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null");
	Тест=ъ(Врап.Новый(Тестовый.ПолучитьСсылку()," Свойство из Конструктора"));
	
	// Это аналог структуры, но с поддержкой методов
	РасширяемыйОбъект=ъ(Тест.ПолучитьExpandoObject());
	
	Сообщить("ВСтроку="+РасширяемыйОбъект.ВСтроку());
	Сообщить("Сумма=" + РасширяемыйОбъект.Сумма(1, 2));
	
	Сообщить(РасширяемыйОбъект.Имя);
	Сообщить(РасширяемыйОбъект.Число);
	
	РасширяемыйОбъект.Имя="Новое Имя";
	РасширяемыйОбъект.Число=768;
	// Добавим новое свойство
	РасширяемыйОбъект.НовоеСвойство="Новое Свойство";
	
	Сообщить(РасширяемыйОбъект.Имя);
	Сообщить(РасширяемыйОбъект.Число);
	Сообщить(РасширяемыйОбъект.НовоеСвойство);
	
	НовыйРеквизит=ъ(Врап.Новый("System.Dynamic.ExpandoObject, System.Dynamic.Runtime, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a"));
	
	НовыйРеквизит.Имя="Новый Реквизит";
	НовыйРеквизит.Значение=123;
	
	
	РасширяемыйОбъект.НовыйРквизит=НовыйРеквизит.ПолучитьСсылку();
	Сообщить(ъ(РасширяемыйОбъект.НовыйРквизит).Имя);
	
	
	TestDynamicObject=ъТип("TestDllForCoreClr.TestDynamicObject, TestDllForCoreClr, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null");
	
	ДинамикОбъект=ъНовый(TestDynamicObject.ПолучитьСсылку());
	ДинамикОбъект.УстановимЛюбоеСвойство="Чего то там";
	Сообщить( ДинамикОбъект.ПолучитТоЧтоПередали);
	Сообщить(ДинамикОбъект.ПолучитТоЧтоПередалиСПараметрами(1,3.45,ТекущаяДата()));




Это удобно при работе с различными парсерами

Теперь стоит поговорить о недостатках 1С реализации Технологии Внешних Компонент.

1. Абсолютно не нужны методы FindMethod, FindProp, IsPropReadable, IsPropWritable, GetNParams, HasRetVal, GetParamDefValue
Так как у методов
bool CallAsProc
bool CallAsFunc
bool SetPropVal и bool GetPropVal есть возвращаемое значение об успешном выполнении
Информация об ошибке возвращается через AddError.
Да и вызов по индексу это анахронизм от IDiapatch где было описание диспинтерфейсов
для увеличения скорости вызова.

2. При возвращении методами SetPropVal и GetPropVal исключение не вызывается
3. Зачем то происходит установка свойств, там где в коде этого не требуется.
4. Вызывается метод как функция, там где метод вызывается как процедура.

5. Один из основных это нельзя вернуть и передать экземпляр ВК из методов ВК.

Я лично не вижу никаких проблем. Определить значение для такого типа и установить ссылку в поле pInterfaceVal.

Подсчет ссылок происходит на стороне 1С. Передавать можно в том числе и объекты 1С только на время вызова метода.

В дальнейшем можно развить до использования событий объектов .Net в 1С по примеру .NET(C#) для 1С. Динамическая компиляция класса обертки для использования .Net событий в 1С через ДобавитьОбработчик или ОбработкаВнешнегоСобытия

Использовать асинхронные вызовы по примеру ".Net в 1С. Асинхронные HTTP запросы, отправка Post нескольких файлов multipart/form-data, сжатие трафика с использованием gzip, deflate, удобный парсинг сайтов и т.д."

Вообще интеграция .Net есть в Microsoft Dynamics AX ClrObject.

Используя кроссплатформенный Core Clr можно интегрировать в 1С. Особенно это актуально для Linux как импортозамещение.
Пока проверил работает на Windows 7,10. Linux и IOS пока нет, но в скором проверю на виртуальной машине. .Net Core CLR можно скачать здесь
С чем я бы с удовольствием помог 1С. Есть огромный опыт использования классов .Net в 1С.

Исходники и тесты можно посмотреть здесь.
Поделиться с друзьями
-->

Комментарии (5)


  1. ifaustrue
    01.07.2016 13:12
    +2

    Ну хаброкат же.


  1. Terranz
    01.07.2016 13:12
    +1

    Кажется, вы забыли убрать под кат


    1. Serginio1
      01.07.2016 13:18

      А как это сделать. Я тут новичек


  1. Serginio1
    01.07.2016 13:21

    Я <cut /> поставил


  1. catHD
    01.07.2016 14:16

    Спойлер в студию