Альтернативные потоки данных NTFS — .NET

Как мне создавать/удалять/читать/записывать/альтернативные потоки данных NTFS из .NET?

Если нет встроенной поддержки .NET, какие Win32 API я бы использовал? Кроме того, как бы я их использовал, поскольку я не думаю, что это задокументировано?


person user72491    schedule 03.03.2009    source источник
comment
Кстати, если вы хотите скопировать файл (ы) со стандартным диалоговым окном процесса копирования файлов, вы не можете использовать ::SHFileOperation() - он вообще не работает с AltDataStreams (проверено в Windows 7). Что касается ::CopyFileEx(), то он работает в некоторых случаях (например, может копировать файл в AltDataStream при вызове обратного вызова прогресса), но не работает в других.   -  person Nishi    schedule 30.11.2009


Ответы (5)


Не в .NET:

http://support.microsoft.com/kb/105763

#include <windows.h>
   #include <stdio.h>

   void main( )
   {
      HANDLE hFile, hStream;
      DWORD dwRet;

      hFile = CreateFile( "testfile",
                       GENERIC_WRITE,
                    FILE_SHARE_WRITE,
                                NULL,
                         OPEN_ALWAYS,
                                   0,
                                NULL );
      if( hFile == INVALID_HANDLE_VALUE )
         printf( "Cannot open testfile\n" );
      else
          WriteFile( hFile, "This is testfile", 16, &dwRet, NULL );

      hStream = CreateFile( "testfile:stream",
                                GENERIC_WRITE,
                             FILE_SHARE_WRITE,
                                         NULL,
                                  OPEN_ALWAYS,
                                            0,
                                         NULL );
      if( hStream == INVALID_HANDLE_VALUE )
         printf( "Cannot open testfile:stream\n" );
      else
         WriteFile(hStream, "This is testfile:stream", 23, &dwRet, NULL);
   }
person Otávio Décio    schedule 03.03.2009
comment
Два отсутствующих вызова CloseHandle... ОС очистится, но это будет проблемой в реальном приложении. - person Richard; 03.03.2009
comment
@Richard - только что скопировано с сайта поддержки MS... - person Otávio Décio; 03.03.2009
comment
ávio Вы можете использовать P/Invoke для этих функций из C#. - person Tim Lloyd; 29.11.2010

Вот версия для С#

using System.Runtime.InteropServices;

class Program
{
    static void Main(string[] args)
    {
        var mainStream = NativeMethods.CreateFileW(
            "testfile",
            NativeConstants.GENERIC_WRITE,
            NativeConstants.FILE_SHARE_WRITE,
            IntPtr.Zero,
            NativeConstants.OPEN_ALWAYS,
            0,
            IntPtr.Zero);

        var stream = NativeMethods.CreateFileW(
            "testfile:stream",
            NativeConstants.GENERIC_WRITE,
            NativeConstants.FILE_SHARE_WRITE,
            IntPtr.Zero,
            NativeConstants.OPEN_ALWAYS,
            0,
            IntPtr.Zero);
    }
}

public partial class NativeMethods
{

    /// Return Type: HANDLE->void*
    ///lpFileName: LPCWSTR->WCHAR*
    ///dwDesiredAccess: DWORD->unsigned int
    ///dwShareMode: DWORD->unsigned int
    ///lpSecurityAttributes: LPSECURITY_ATTRIBUTES->_SECURITY_ATTRIBUTES*
    ///dwCreationDisposition: DWORD->unsigned int
    ///dwFlagsAndAttributes: DWORD->unsigned int
    ///hTemplateFile: HANDLE->void*
    [DllImportAttribute("kernel32.dll", EntryPoint = "CreateFileW")]
    public static extern System.IntPtr CreateFileW(
        [InAttribute()] [MarshalAsAttribute(UnmanagedType.LPWStr)] string lpFileName, 
        uint dwDesiredAccess, 
        uint dwShareMode, 
        [InAttribute()] System.IntPtr lpSecurityAttributes, 
        uint dwCreationDisposition, 
        uint dwFlagsAndAttributes, 
        [InAttribute()] System.IntPtr hTemplateFile
    );

}


public partial class NativeConstants
{

    /// GENERIC_WRITE -> (0x40000000L)
    public const int GENERIC_WRITE = 1073741824;

    /// FILE_SHARE_DELETE -> 0x00000004
    public const int FILE_SHARE_DELETE = 4;

    /// FILE_SHARE_WRITE -> 0x00000002
    public const int FILE_SHARE_WRITE = 2;

    /// FILE_SHARE_READ -> 0x00000001
    public const int FILE_SHARE_READ = 1;

    /// OPEN_ALWAYS -> 4
    public const int OPEN_ALWAYS = 4;
}
person JaredPar    schedule 03.03.2009
comment
Здесь следует использовать тип, производный от SafeHandle, чтобы убедиться, что вы очистили эти дескрипторы файлов. - person Richard; 03.03.2009
comment
Вы показали, как использовать нативные API, но не показали, как использовать указатель, возвращенный из CreateFileW. Мне действительно хотелось бы увидеть более полный пример, который записывает общие свойства, доступные на вкладке «Сводка» свойств файла в проводнике Windows. - person Bernhard Hofmann; 19.03.2013

Для них нет встроенной поддержки .NET. Вы должны использовать P/Invoke для вызова собственных методов Win32.

Чтобы создать их, вызовите CreateFile с путем, например, filename.txt:streamname. Если вы используете вызов взаимодействия, который возвращает SafeFileHandle, вы можете использовать его для создания FileStream, который затем можно читать и записывать.

Чтобы просмотреть существующие в файле потоки, используйте FindFirstStreamW и FindNextStreamW (существуют только на сервере 2003 и более поздних версиях — не XP).

Я не думаю, что вы можете удалить поток, кроме как скопировав остальную часть файла и опустив один из потоков. Установка длины на 0 также может работать, но я не пробовал.

Вы также можете иметь альтернативные потоки данных в каталоге. Доступ к ним такой же, как и к файлам - C:\some\directory:streamname.

Потоки могут иметь сжатие, шифрование и разреженность, установленные для них независимо от потока по умолчанию.

person Zack Elan    schedule 03.03.2009
comment
Вы можете удалить поток: просто вызовите API DeleteFile с именем файла:имя потока. По-видимому, с ADS можно делать почти все, что можно делать с обычным файлом. Единственная причина, по которой FileStream не справляется с этим, заключается в том, что он проверяет путь и терпит неудачу, если он содержит:... - person Thomas Levesque; 27.10.2013

Этот пакет nuget CodeFluent Runtime Client имеет (среди других утилит) Класс NtfsAlternateStream, который поддерживает операции создания/чтения/обновления/удаления/перечисления.

person Simon Mourier    schedule 23.01.2013

О. Во-первых, в Microsoft® .NET Framework нет такой возможности. Если вы хотите, просто и ясно, вам нужно будет выполнить какое-то взаимодействие, либо напрямую, либо с помощью сторонней библиотеки.

Если вы используете Windows Server™ 2003 или более позднюю версию, Kernel32.dll предоставляет аналоги FindFirstFile и FindNextFile, которые обеспечивают именно те функции, которые вам нужны. FindFirstStreamW и FindNextStreamW позволяют находить и перечислять все альтернативные потоки данных в определенном файле, извлекая информацию о каждом, включая его имя и длину. Код для использования этих функций из управляемого кода очень похож на тот, который я показал в своей декабрьской колонке, и показан на рисунке 1.

Рис. 1 Использование FindFirstStreamW и FindNextStreamW

[SecurityPermission(SecurityAction.LinkDemand, UnmanagedCode = true)]
public sealed class SafeFindHandle : SafeHandleZeroOrMinusOneIsInvalid {

    private SafeFindHandle() : base(true) { }

    protected override bool ReleaseHandle() {
        return FindClose(this.handle);
    }

    [DllImport("kernel32.dll")]
    [return: MarshalAs(UnmanagedType.Bool)]
    [ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
    private static extern bool FindClose(IntPtr handle);

}

public class FileStreamSearcher {
    private const int ERROR_HANDLE_EOF = 38;
    private enum StreamInfoLevels { FindStreamInfoStandard = 0 }

    [DllImport("kernel32.dll", ExactSpelling = true, CharSet = CharSet.Auto, SetLastError = true)]
    private static extern SafeFindHandle FindFirstStreamW(string lpFileName, StreamInfoLevels InfoLevel, [In, Out, MarshalAs(UnmanagedType.LPStruct)] WIN32_FIND_STREAM_DATA lpFindStreamData, uint dwFlags);

    [DllImport("kernel32.dll", ExactSpelling = true, CharSet = CharSet.Auto, SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool FindNextStreamW(SafeFindHandle hndFindFile, [In, Out, MarshalAs(UnmanagedType.LPStruct)] WIN32_FIND_STREAM_DATA lpFindStreamData);
    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
    private class WIN32_FIND_STREAM_DATA {
        public long StreamSize;
        [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 296)]
        public string cStreamName;
    }

    public static IEnumerable<string> GetStreams(FileInfo file) {
        if (file == null) throw new ArgumentNullException("file");
        WIN32_FIND_STREAM_DATA findStreamData = new WIN32_FIND_STREAM_DATA();
        SafeFindHandle handle = FindFirstStreamW(file.FullName, StreamInfoLevels.FindStreamInfoStandard, findStreamData, 0);
        if (handle.IsInvalid) throw new Win32Exception();
        try {
            do {
                yield return findStreamData.cStreamName;
            } while (FindNextStreamW(handle, findStreamData));
            int lastError = Marshal.GetLastWin32Error();
            if (lastError != ERROR_HANDLE_EOF) throw new Win32Exception(lastError);
        } finally {
            handle.Dispose();
        }
    }
}

Вы просто вызываете FindFirstStreamW, передавая ему полный путь к целевому файлу. Второй параметр FindFirstStreamW определяет уровень детализации возвращаемых данных; в настоящее время существует только один уровень (FindStreamInfoStandard), который имеет числовое значение 0. Третий параметр функции — это указатель на структуру WIN32_FIND_STREAM_DATA (технически то, на что указывает третий параметр, определяется значением второго параметра с подробным описанием уровня информации, но поскольку в настоящее время существует только один уровень, во всех смыслах и целях это WIN32_FIND_STREAM_DATA). Я объявил управляемый аналог этой структуры как класс и в сигнатуре взаимодействия пометил его для маршалинга как указатель на структуру. Последний параметр зарезервирован для использования в будущем и должен быть равен 0. Если из FindFirstStreamW возвращается допустимый дескриптор, экземпляр WIN32_FIND_STREAM_DATA содержит информацию о найденном потоке, и его значение cStreamName может быть возвращено вызывающей стороне в качестве первого доступного имени потока. FindNextStreamW принимает дескриптор, возвращенный из FindFirstStreamW, и заполняет предоставленный WIN32_FIND_STREAM_DATA информацией о следующем доступном потоке, если он существует. FindNextStreamW возвращает true, если доступен другой поток, или false, если нет. В результате я постоянно вызываю FindNextStreamW и выдаю результирующее имя потока, пока FindNextStreamW не вернет false. Когда это происходит, я дважды проверяю последнее значение ошибки, чтобы убедиться, что итерация остановилась из-за того, что в FindNextStreamW закончились потоки, а не по какой-то неожиданной причине. К сожалению, если вы используете Windows® XP или Windows 2000 Server, эти функции вам недоступны, но есть пара альтернатив. Первое решение включает недокументированную функцию, которая в настоящее время экспортируется из Kernel32.dll, NTQueryInformationFile. Однако недокументированные функции недокументированы по какой-то причине, и их можно изменить или даже удалить в любое время в будущем. Их лучше не использовать. Если вы хотите использовать эту функцию, поищите в Интернете, и вы найдете множество ссылок и образцов исходного кода. Но делайте это на свой страх и риск. Другое решение, которое я продемонстрировал на Рис. 2, основано на двух функциях, экспортированных из Kernel32.dll, и они задокументированы. Как следует из их названий, BackupRead и BackupSeek являются частью Win32® API для поддержки резервного копирования:

BOOL BackupRead(HANDLE hFile, LPBYTE lpBuffer, DWORD nNumberOfBytesToRead, LPDWORD lpNumberOfBytesRead, BOOL bAbort, BOOL bProcessSecurity, LPVOID* lpContext);
BOOL BackupSeek(HANDLE hFile, DWORD dwLowBytesToSeek, DWORD dwHighBytesToSeek, LPDWORD lpdwLowByteSeeked, LPDWORD lpdwHighByteSeeked, LPVOID* lpContext);

Рис. 2 Использование BackupRead и BackupSeek

public enum StreamType {
    Data = 1,
    ExternalData = 2,
    SecurityData = 3,
    AlternateData = 4,
    Link = 5,
    PropertyData = 6,
    ObjectID = 7,
    ReparseData = 8,
    SparseDock = 9
}

public struct StreamInfo {
    public StreamInfo(string name, StreamType type, long size) {
        Name = name;
        Type = type;
        Size = size;
    }
    readonly string Name;
    public readonly StreamType Type;
    public readonly long Size;
}

public class FileStreamSearcher {
    [DllImport("kernel32.dll")]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool BackupRead(SafeFileHandle hFile, IntPtr lpBuffer, uint nNumberOfBytesToRead, out uint lpNumberOfBytesRead, [MarshalAs(UnmanagedType.Bool)] bool bAbort, [MarshalAs(UnmanagedType.Bool)] bool bProcessSecurity, ref IntPtr lpContext);[DllImport("kernel32.dll")]

    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool BackupSeek(SafeFileHandle hFile, uint dwLowBytesToSeek, uint dwHighBytesToSeek, out uint lpdwLowByteSeeked, out uint lpdwHighByteSeeked, ref IntPtr lpContext); public static IEnumerable<StreamInfo> GetStreams(FileInfo file) {
        const int bufferSize = 4096;
        using (FileStream fs = file.OpenRead()) {
            IntPtr context = IntPtr.Zero;
            IntPtr buffer = Marshal.AllocHGlobal(bufferSize);
            try {
                while (true) {
                    uint numRead;
                    if (!BackupRead(fs.SafeFileHandle, buffer, (uint)Marshal.SizeOf(typeof(Win32StreamID)), out numRead, false, true, ref context)) throw new Win32Exception();
                    if (numRead > 0) {
                        Win32StreamID streamID = (Win32StreamID)Marshal.PtrToStructure(buffer, typeof(Win32StreamID));
                        string name = null;
                        if (streamID.dwStreamNameSize > 0) {
                            if (!BackupRead(fs.SafeFileHandle, buffer, (uint)Math.Min(bufferSize, streamID.dwStreamNameSize), out numRead, false, true, ref context)) throw new Win32Exception(); name = Marshal.PtrToStringUni(buffer, (int)numRead / 2);
                        }
                        yield return new StreamInfo(name, streamID.dwStreamId, streamID.Size);
                        if (streamID.Size > 0) {
                            uint lo, hi; BackupSeek(fs.SafeFileHandle, uint.MaxValue, int.MaxValue, out lo, out hi, ref context);
                        }
                    } else break;
                }
            } finally {
                Marshal.FreeHGlobal(buffer);
                uint numRead;
                if (!BackupRead(fs.SafeFileHandle, IntPtr.Zero, 0, out numRead, true, false, ref context)) throw new Win32Exception();
            }
        }
    }
}

Идея BackupRead заключается в том, что ее можно использовать для чтения данных из файла в буфер, которые затем можно записать на носитель резервного копирования. Однако BackupRead также очень удобен для получения информации о каждом из альтернативных потоков данных, составляющих целевой файл. Он обрабатывает все данные в файле как ряд дискретных потоков байтов (каждый альтернативный поток данных является одним из этих потоков байтов), и каждому из потоков предшествует структура WIN32_STREAM_ID. Таким образом, чтобы перечислить все потоки, вам просто нужно прочитать все эти структуры WIN32_STREAM_ID с начала каждого потока (именно здесь BackupSeek становится очень удобным, так как его можно использовать для перехода от потока к потоку без прочитать все данные в файле). Для начала вам сначала нужно создать управляемый аналог для неуправляемой структуры WIN32_STREAM_ID:

typedef struct _WIN32_STREAM_ID { 
    DWORD dwStreamId; DWORD dwStreamAttributes;
    LARGE_INTEGER Size; 
    DWORD dwStreamNameSize; 
    WCHAR cStreamName[ANYSIZE_ARRAY];
} WIN32_STREAM_ID;

По большей части это похоже на любую другую структуру, которую вы маршалируете через P/Invoke. Однако есть несколько сложностей. Прежде всего, WIN32_STREAM_ID — это структура переменного размера. Его последний член, cStreamName, представляет собой массив длиной ANYSIZE_ARRAY. В то время как ANYSIZE_ARRAY определен равным 1, cStreamName — это просто адрес остальных данных в структуре после предыдущих четырех полей, что означает, что если структура выделяется больше, чем sizeof (WIN32_STREAM_ID) байтов, это дополнительное пространство будет фактически быть частью массива cStreamName. Предыдущее поле, dwStreamNameSize, точно определяет длину массива. Хотя это отлично подходит для разработки Win32, это наносит ущерб маршалеру, которому необходимо скопировать эти данные из неуправляемой памяти в управляемую память как часть вызова взаимодействия с BackupRead. Как маршалер узнает, насколько большой на самом деле является структура WIN32_STREAM_ID, учитывая, что она имеет переменный размер? Это не так. Вторая проблема связана с упаковкой и выравниванием. На мгновение игнорируя cStreamName, рассмотрите следующую возможность для вашего управляемого аналога WIN32_STREAM_ID:

[StructLayout(LayoutKind.Sequential)] 
public struct Win32StreamID { 
    public int dwStreamId; 
    public int dwStreamAttributes; 
    public long Size; 
    public int dwStreamNameSize;
}

Int32 имеет размер 4 байта, а Int64 — 8 байт. Таким образом, вы ожидаете, что эта структура будет иметь размер 20 байт. Однако, если вы запустите следующий код, вы обнаружите, что оба значения равны 24, а не 20:

int size1 = Marshal.SizeOf(typeof(Win32StreamID));
int size2 = sizeof(Win32StreamID); // in an unsafe context

Проблема в том, что компилятор хочет убедиться, что значения в этих структурах всегда выровнены по правильной границе. Четырехбайтовые значения должны находиться по адресам, кратным 4, 8-байтовые значения должны находиться по границам, кратным 8, и так далее. Теперь представьте, что произойдет, если вы создадите массив структур Win32StreamID. Все поля в первом экземпляре массива будут правильно выровнены. Например, поскольку поле «Размер» следует за двумя 32-битными целыми числами, это будет 8 байтов от начала массива, что идеально подходит для 8-байтового значения. Однако, если бы структура имела размер 20 байт, во втором экземпляре массива не все элементы были бы правильно выровнены. Все целочисленные значения будут в порядке, но длинное значение будет равно 28 байтам от начала массива, значение, которое не делится без остатка на 8. Чтобы исправить это, компилятор дополняет структуру до размера 24, так что все поля всегда будут правильно выровнены (при условии, что сам массив). Если компилятор все делает правильно, вам может быть интересно, почему меня это беспокоит. Вы поймете, почему, если посмотрите на код на рис. 2. Чтобы обойти описанную мной первую проблему маршалинга, я фактически исключил cStreamName из структуры Win32StreamID. Я использую BackupRead для считывания байтов, достаточных для заполнения моей структуры Win32StreamID, а затем проверяю поле dwStreamNameSize этой структуры. Теперь, когда я знаю длину имени, я могу снова использовать BackupRead для чтения значения строки из файла. Это все хорошо, но если Marshal.SizeOf вернет 24 для моей структуры Win32StreamID вместо 20, я попытаюсь прочитать слишком много данных. Чтобы избежать этого, мне нужно убедиться, что размер Win32StreamID на самом деле равен 20, а не 24. Этого можно добиться двумя разными способами, используя поля в атрибуте StructLayoutAttribute, который украшает структуру. Первый заключается в использовании поля «Размер», которое указывает среде выполнения, насколько большой должна быть структура:

[StructLayout(LayoutKind.Sequential, Size = 20)]

Второй вариант — использовать поле Pack. Pack указывает размер упаковки, который следует использовать, когда указано значение LayoutKind.Sequential, и управляет выравниванием полей в структуре. Размер упаковки по умолчанию для управляемой структуры равен 8. Если я изменю это значение на 4, я получу 20-байтовую структуру, которую ищу (и поскольку я на самом деле не использую это в массиве, я не теряю эффективность). или стабильность, которая может возникнуть в результате такого изменения упаковки):

[StructLayout(LayoutKind.Sequential, Pack = 4)]
public struct Win32StreamID {
    public StreamType dwStreamId;
    public int dwStreamAttributes;
    public long Size;
    public int dwStreamNameSize; // WCHAR cStreamName[1];
}

Имея этот код, я теперь могу перечислить все потоки в файле, как показано здесь:

static void Main(string[] args) {
    foreach (string path in args) {
        Console.WriteLine(path + ":");
        foreach (StreamInfo stream in FileStreamSearcher.GetStreams(new FileInfo(path))) {
            Console.WriteLine("\t{0}\t{1}\t{2}", stream.Name != null ? stream.Name : "(unnamed)", stream.Type, stream.Size);
        }
    }
}

Вы заметите, что эта версия FileStreamSearcher возвращает больше информации, чем версия, использующая FindFirstStreamW и FindNextStreamW. BackupRead может предоставлять данные не только по основному потоку и альтернативным потокам данных, но также работать с потоками, содержащими информацию о безопасности, данные повторной обработки и многое другое. Если вы хотите видеть только альтернативные потоки данных, вы можете фильтровать на основе свойства Type StreamInfo, которое будет StreamType.AlternateData для альтернативных потоков данных. Чтобы протестировать этот код, вы можете создать файл с альтернативными потоками данных, используя команду echo в командной строке:

> echo ".NET Matters" > C:\test.txt
> echo "MSDN Magazine" > C:\test.txt:magStream
> StreamEnumerator.exe C:\test.txt
test.txt:
        (unnamed)               SecurityData    164
        (unnamed)               Data            17
        :magStream:$DATA        AlternateData   18
> type C:\test.txt
".NET Matters"
> more < C:\test.txt:magStream
"MSDN Magazine"

Итак, теперь вы можете получить имена всех альтернативных потоков данных, хранящихся в файле. Здорово. Но что, если вы действительно хотите манипулировать данными в одном из этих потоков? К сожалению, если вы попытаетесь передать путь для альтернативного потока данных одному из конструкторов FileStream, будет выдано исключение NotSupportedException: «Формат данного пути не поддерживается». Чтобы обойти это, вы можете обойти проверки FileStream на канонизацию пути, напрямую обратившись к функции CreateFile из kernel32.dll (см. рис. 3). Я использовал P/Invoke для функции CreateFile, чтобы открыть и получить SafeFileHandle для указанного пути, не выполняя никаких управляемых проверок разрешений на пути, поэтому он может включать идентификаторы альтернативного потока данных. Затем этот SafeFileHandle используется для создания нового управляемого потока FileStream, обеспечивающего требуемый доступ. Имея это, можно легко манипулировать содержимым альтернативного потока данных, используя функциональные возможности пространства имен System.IO. В следующем примере считывается и распечатывается содержимое C:\test.txt:magStream, созданное в предыдущем примере:

string path = @"C:\test.txt:magStream"; 
using (StreamReader reader = new StreamReader(CreateFileStream(path, FileAccess.Read, FileMode.Open, FileShare.Read))) { 
    Console.WriteLine(reader.ReadToEnd());
}

Рис. 3 Использование P/Invoke для CreateFile

private static FileStream CreateFileStream(string path, FileAccess access, FileMode mode, FileShare share) {
    if (mode == FileMode.Append) mode = FileMode.OpenOrCreate; SafeFileHandle handle = CreateFile(path, access, share, IntPtr.Zero, mode, 0, IntPtr.Zero);
    if (handle.IsInvalid) throw new IOException("Could not open file stream.", new Win32Exception());
    return new FileStream(handle, access);
}

[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern SafeFileHandle CreateFile(string lpFileName, FileAccess dwDesiredAccess, FileShare dwShareMode, IntPtr lpSecurityAttributes, FileMode dwCreationDisposition, int dwFlagsAndAttributes, IntPtr hTemplateFile);

Стивен Туб в Журнал MSDN за январь 2006 г..

person Václav Dajbych    schedule 02.11.2011
comment
Хороший пример того, почему ответы, содержащие только ссылки, плохи. - person Carey Gregory; 21.09.2016
comment
Все ссылки на журналы MSDN не работают, и ссылки на веб-сайт MSDN скоро тоже не работают. Пожалуйста, включите более подробную информацию о вашем ответе. - person AaA; 10.04.2017