Мне нужно заменить строку в потоковом ответе HTTP. Наивный способ сделать это был бы
using var reader = new StreamReader(input, leaveOpen: true);
var original = await reader.ReadToEndAsync();
var replaced = original.Replace(old, new, StringComparison.InvariantCultureIgnoreCase);
await output.WriteAsync(Encoding.UTF8.GetBytes(replaced));
Это требует очень много ресурсов и памяти, поскольку перед заменой строк необходимо считать полный ответ в память.
Я смотрел на System.IO.Pipelines и PipeReader. Хотя это дает мне эффективный доступ к потоку, оно работает на byte, что делает проблематичным преобразование в char при работе в Utf-8.
Один из методов, который я видел, — использовать ReadLineAsync в потоке чтения, но я не знаю, будет ли поток содержать какие-либо символы новой строки.
Другой метод, который я видел, — использование очереди, но даже он кажется неуклюжим.
Итак, мой вопрос: как лучше всего заменить текст потоком без чтения всего потока в памяти?
Учтите, что вам нужен буфер, который, возможно, в 2 раза превышает длину искомой строки, вы заполняете его, затем выполняете поиск, выдаете половину буфера, перемещаете вторую половину поверх первой и добавляете ко второй половине буфера, снова выполняете поиск. Таким образом, вы просматриваете данные в скользящем окне, которое в какой-то момент будет достаточно большим, чтобы вместить искомую строку, и будет содержать ее, если она будет найдена. Вы можете настроить размер буфера. Другой наивной реализацией было бы чтение и выдача по 1 символу за раз, и как только символ совпадает с началом искомой строки, буферизируйте длину и проверьте.
(Но будьте осторожны и вскоре вернитесь в односимвольный режим; если вы читаете одиночный символ в поисках «привет» в потоке «wellhellhellothere», как только вы найдете первый h, поместите в буфер 5 байтов и проверьте, повторите «привет», но не выводите все 5 за один раз, если это не так, потому что вы пропустите истинное приветствие, если выведете его h как часть 5)
У меня проблемы со скользящей частью. Вы просто копируете символ за символом в правильные позиции?
Какой смысл в «замене», если вы не читаете полный HTTP-ответ? Вы читаете ответ, находите токен и заменяете его. Вы имеете в виду «заменить» или «найти»? Оба имеют большое значение.
@BionicCode Мне нужно прочитать полный ответ, чтобы заменить часть текста, но хранить для этого полный ответ в памяти не очень эффективно. Это критический путь приложения, поэтому мне нужно, чтобы оно работало эффективно.
Решение опубликую позже. Я не ожидаю какого-либо существенного улучшения производительности при замене токенов на лету, в отличие от их замены после получения результата. Вы не хотите возиться с размером блоков, которые вы читаете из потока. Ваша идея имеет смысл для бесконечных потоков, таких как потоки сокетов. Но для конечных потоков я не ожидаю каких-либо преимуществ.
Я согласен с @BionicCode. Является ли ответ настолько огромным, что вы просто не можете позволить себе сохранить его в памяти на промежуточном этапе перед записью в output? В противном случае любое улучшение производительности будет практически незаметным.





Моя мысль:
old[0] в буфере:
old:
old, сначала выведите символы перед позицией old[0], затем выведите new. Вернитесь к шагу 2.old[0], затем переместите оставшиеся символы в начало буфера. Перейдите к шагу 1.Не возникнет ли у них по-прежнему проблем с символами UTF-8 и более высокого порядка? Я думаю, им придется декодировать UTF-8 вручную, а также искать последовательность в стиле конечного автомата.
Если у вас есть бесконечный Stream, например NetworkStream, замена строковых токенов «на лету» имела бы большой смысл. Но поскольку вы обрабатываете ограниченный поток, для которого вам нужен полный контент, такая фильтрация не имеет смысла из-за влияния на производительность.
Мой аргумент заключается в том, что вам придется использовать буферизацию. Размер буфера, конечно, ограничивает количество символов, которые вы можете обработать. Предполагая, что вы используете кольцевой буфер или очередь, вам придется удалить один символ, чтобы добавить новый. Это приводит к множеству недостатков по сравнению с обработкой всего контента.
За и против
string выделений, которые происходят во время поиска и замены.
👍 Выделяется только размер буферов. Однако в данном случае это не имеет значения, поскольку мы все равно сохраним полный ответ в памяти (клиента).
👎 Не позволяет фильтровать поток в режиме реального времени (не актуально в данном случае)). Однако возможно гибридное решение, при котором мы будем фильтровать определенные части в режиме реального времени (например, преамбулу).
👍 Позволяет фильтровать поток в режиме реального времени, чтобы мы могли принимать решения на основе контента, например. прервать чтение (в данном случае не имеет значения).
На этом я останавливаюсь, так как поиск и замена наиболее релевантных затрат на производительность лучше подходят для решения с полным содержанием. Для поиска и замены в реальном времени нам, по сути, придется реализовать наш собственный алгоритм, который должен конкурировать с алгоритмами поиска и замены .NET. Нет проблем, но, учитывая затраченные усилия и конечный вариант использования, я бы не стал тратить на это время.
Эффективным решением может быть реализация пользовательского TextReader, расширенного StreamReader, который работает на основе StringBuilder для поиска и замены символов. Хотя StringBuilder предлагает значительное преимущество в производительности по сравнению с поиском и заменой строк, он не позволяет использовать сложные шаблоны поиска, такие как границы слов. Например, границы слов возможны только в том случае, если шаблон явно включает ограничивающие символы.
Например, замена «int» во входных данных «внутренний int» на «pat» дает «отцовский pat». Если мы хотим заменить только «int» на «internal int», нам придется использовать регулярное выражение. Поскольку регулярное выражение работает только с string, нам приходится платить эффективностью.
В следующем примере реализуется StringReplaceStreamReader, который расширяет TextReader и действует как специализированный StreamReader. Для лучшей производительности токены заменяются после прочтения всего потока.
Для краткости он поддерживает только методы ReadToEndAsync, Read и Peak.
Он поддерживает простой поиск, при котором шаблон поиска просто сопоставляется с входными данными (так называемый простой поиск).
Кроме того, он также поддерживает два варианта поиска и замены по регулярным выражениям для более расширенных сценариев поиска и замены.
Первый вариант основан на наборе пар ключ-значение, а второй вариант использует шаблон регулярного выражения, предоставленный вызывающей стороной.
Поскольку простой поиск включает в себя итерацию записей исходного словаря + несколько проходов (по одному для каждой записи), ожидается, что этот режим поиска будет самым медленным алгоритмом, хотя замена с использованием самого StringBuilder на самом деле выполняется быстрее. В этих обстоятельствах ожидается, что поиск и замена Regex будут значительно быстрее, чем простой поиск и замена с использованием StringBuilder, поскольку он может обрабатывать входные данные за один проход.
Поведение поиска StringReplaceStreamReader настраивается через конструктор.
private Dictionary<string, string> replacementTable;
private async Task ReplaceInStream(Stream sourceStream)
{
this.replacementTable = new Dictionary<string, string>
{
{ "private", "internal" },
{ "int", "BUTTON" },
{ "Read", "Not-To-Read" }
};
// Search and replace using simple search
// (slowest).
await using var streamReader = new StringReplaceStreamReader(sourceStream, Encoding.Default, this.replacementTable, StringComparison.OrdinalIgnoreCase);
string text = await streamReader.ReadToEndAsync();
// Search and replace variant #1 using regex with key-value pairs instead of a regular expression pattern
// for advanced scenarios (fast)
await using var streamReader2 = new StringReplaceStreamReader(sourceStream, Encoding.Default, this.replacementTable, SearchMode.Regex);
string text2 = await streamReader2.ReadToEndAsync();
// Search and replace variant #2 using regex and regular expression pattern
// for advanced scenarios (fast).
// The matchEvaluator callback actually provides the replcement value for each match.
// Creates the following regular expression:
// "\bprivate\b|\bint\b|\bRead\b"
string searchPattern = this.replacementTable.Keys
.Select(key => $@"\b{key}\b")
.Aggregate((current, newValue) => $"{current}|{newValue}");
await using var streamReader3 = new StringReplaceStreamReader(sourceStream, Encoding.Default, searchPattern, Replace);
string text3 = await streamReader.ReadToEndAsync();
}
private string Replace(Match match)
=> this.replacementTable.TryGetValue(match.Value, out string replacement)
? replacement
: match.Value;
Режим поиска
public enum SearchMode
{
Default = 0,
Simple,
Regex
}
StringReplaceStreamReader.cs
public class StringReplaceStreamReader : TextReader, IDisposable, IAsyncDisposable
{
public Stream BaseStream { get; }
public long Length => this.BaseStream.Length;
public bool EndOfStream => this.BaseStream.Position == this.BaseStream.Length;
public SearchMode SearchMode { get; }
private const int DefaultCapacity = 4096;
private readonly IDictionary<string, string> stringReplaceTable;
private readonly StringComparison stringComparison;
private readonly Encoding encoding;
private readonly Decoder decoder;
private readonly MatchEvaluator? matchEvaluator;
private readonly Regex? regularExpression;
private readonly byte[] byteBuffer;
private readonly char[] charBuffer;
public StringReplaceStreamReader(Stream stream, Encoding encoding, IDictionary<string, string> stringReplaceTable)
: this(stream, encoding, stringReplaceTable, StringComparison.OrdinalIgnoreCase, SearchMode.Simple)
{
}
public StringReplaceStreamReader(Stream stream, Encoding encoding, IDictionary<string, string> stringReplaceTable, SearchMode searchMode)
: this(stream, encoding, stringReplaceTable, StringComparison.OrdinalIgnoreCase, searchMode)
{
}
public StringReplaceStreamReader(Stream stream, Encoding encoding, IDictionary<string, string> stringReplaceTable, StringComparison stringComparison)
: this(stream, encoding, stringReplaceTable, stringComparison, SearchMode.Simple)
{
}
public StringReplaceStreamReader(Stream stream, Encoding encoding, IDictionary<string, string> stringReplaceTable, StringComparison stringComparison, SearchMode searchMode)
{
ArgumentNullException.ThrowIfNull(stream, nameof(stream));
ArgumentNullException.ThrowIfNull(stringReplaceTable, nameof(stringReplaceTable));
this.BaseStream = stream;
this.encoding = encoding ?? Encoding.Default;
this.decoder = this.encoding.GetDecoder();
this.stringReplaceTable = stringReplaceTable;
this.stringComparison = stringComparison;
this.SearchMode = searchMode;
this.regularExpression = null;
this.matchEvaluator = ReplaceMatch;
if (searchMode is SearchMode.Regex)
{
RegexOptions regexOptions = CreateDefaultRegexOptions(stringComparison);
var searchPatternBuilder = new StringBuilder();
foreach (KeyValuePair<string, string> entry in stringReplaceTable)
{
// Creates the following regular expression:
// "\b[search_key]\b|\b[search_key]\b"
string pattern = @$"\b{entry.Key}\b";
searchPatternBuilder.Append(pattern);
searchPatternBuilder.Append('|');
}
string searchPattern = searchPatternBuilder.ToString().TrimEnd('|');
this.regularExpression = new Regex(searchPattern, regexOptions);
}
this.byteBuffer = new byte[StringReplaceStreamReader.DefaultCapacity];
int charBufferSize = this.encoding.GetMaxCharCount(this.byteBuffer.Length);
this.charBuffer = new char[charBufferSize];
}
public StringReplaceStreamReader(Stream stream, Encoding encoding, string searchAndReplacePattern, MatchEvaluator matchEvaluator)
: this(stream, encoding, searchAndReplacePattern, matchEvaluator, RegexOptions.None)
{
}
public StringReplaceStreamReader(Stream stream, Encoding encoding, string searchAndReplacePattern, MatchEvaluator matchEvaluator, RegexOptions regexOptions)
{
ArgumentNullException.ThrowIfNull(stream, nameof(stream));
ArgumentNullException.ThrowIfNullOrWhiteSpace(searchAndReplacePattern, nameof(searchAndReplacePattern));
ArgumentNullException.ThrowIfNull(matchEvaluator, nameof(matchEvaluator));
this.BaseStream = stream;
this.encoding = encoding ?? Encoding.Default;
this.decoder = this.encoding.GetDecoder();
this.matchEvaluator = matchEvaluator;
this.SearchMode = SearchMode.Regex;
this.stringReplaceTable = new Dictionary<string, string>();
this.stringComparison = StringComparison.OrdinalIgnoreCase;
if (regexOptions is RegexOptions.None)
{
regexOptions = CreateDefaultRegexOptions(stringComparison);
}
else if ((regexOptions & RegexOptions.Compiled) == 0)
{
regexOptions |= RegexOptions.Compiled;
}
this.regularExpression = new Regex(searchAndReplacePattern, regexOptions);
this.byteBuffer = new byte[StringReplaceStreamReader.DefaultCapacity];
int charBufferSize = this.encoding.GetMaxCharCount(this.byteBuffer.Length);
this.charBuffer = new char[charBufferSize];
}
public override int Peek()
{
int value = Read();
this.BaseStream.Seek(this.BaseStream.Position - 1, SeekOrigin.Begin);
return value;
}
public override int Read()
=> this.BaseStream.ReadByte();
public override Task<string> ReadToEndAsync()
=> ReadToEndAsync(CancellationToken.None);
public override async Task<string> ReadToEndAsync(CancellationToken cancellationToken)
{
if (!this.BaseStream.CanRead)
{
throw new InvalidOperationException("Source stream is not readable.");
}
var textBuilder = new StringBuilder(StringReplaceStreamReader.DefaultCapacity);
while (!this.EndOfStream)
{
cancellationToken.ThrowIfCancellationRequested();
int bytesRead = await this.BaseStream.ReadAsync(buffer, 0, buffer.Length);
bool flush = this.EndOfStream;
int charsRead = this.decoder.GetChars(this.byteBuffer, 0, bytesRead, this.charBuffer, 0, flush);
textBuilder.Append(charBuffer, 0, charsRead);
}
cancellationToken.ThrowIfCancellationRequested();
SearchAndReplace(textBuilder, cancellationToken, out string result);
return result;
}
public ValueTask DisposeAsync()
=> ((IAsyncDisposable)this.BaseStream).DisposeAsync();
private void SearchAndReplace(StringBuilder textBuilder, CancellationToken cancellationToken, out string result)
{
cancellationToken.ThrowIfCancellationRequested();
if (this.SearchMode is SearchMode.Simple or SearchMode.Default)
{
foreach (KeyValuePair<string, string> entry in this.stringReplaceTable)
{
cancellationToken.ThrowIfCancellationRequested();
textBuilder.Replace(entry.Key, entry.Value);
}
result = textBuilder.ToString();
}
else if (this.SearchMode is SearchMode.Regex)
{
string input = textBuilder.ToString();
result = this.regularExpression!.Replace(input, this.matchEvaluator!);
}
else
{
throw new NotImplementedException($"Search mode {this.SearchMode} is not implemented.");
}
}
private string ReplaceMatch(Match match)
=> this.stringReplaceTable.TryGetValue(match.Value, out string replacement)
? replacement
: match.Value;
private RegexOptions CreateDefaultRegexOptions(StringComparison stringComparison)
{
RegexOptions regexOptions = RegexOptions.Multiline | RegexOptions.Compiled;
if (stringComparison is StringComparison.CurrentCultureIgnoreCase or StringComparison.InvariantCultureIgnoreCase or StringComparison.OrdinalIgnoreCase)
{
regexOptions |= RegexOptions.IgnoreCase;
}
return regexOptions;
}
}
Нет ли по-прежнему проблемы с UTF8, если конец буфера находится в середине многобайтового символа?
Да, он неправильно обрабатывал многобайтовые символы. Сейчас это исправлено и улучшено. Спасибо за подсказку.
Придирка: декодер никогда не назначается, но я думаю, что это так encoding.GetDecoder()
Спасибо за ваш подробный ответ. Почему бы вам просто не использовать StreamReader, чтобы выполнить декодирование за вас? Мне также нужно, чтобы поиск был нечувствителен к регистру, поэтому мне нужно использовать опцию медленного регулярного выражения. Я проверю это и сравню с тем, что я приготовил.
@Lodewijk Да, действительно, я пропустил его вставку. Теперь поле правильно инициализировано. Я не использовал StreamReader внутри, потому что изначально мне нужно было больше контроля над моментом завершения байтовых данных для создания string. Я также пытался найти решение для оперативного поиска и замены. Но затем я изменил алгоритм, отказался от поиска и замены «на лету» из-за плохой эффективности для вашего сценария.
@Lodewijk Если вы хотите удалить функцию поиска и замены по словарю (простой поиск без регулярных выражений), я бы расширил StreamReader, а не обернул его. Затем переопределите члены ReadToEndAsync и ReadAsync. Вызовите базовую реализацию и обработайте результат. Но если вы хотите сохранить эту функцию, StreamReader — не лучший вариант, поскольку API возвращает только string.
@Lodewijk Это связано с тем, что мы должны использовать StringBuilder для повышения эффективности поиска и замены (в частности, замена, поскольку string является неизменяемой, а замена символов экземпляра string означает, что мы всегда создаем новый экземпляр), мы не можем использовать StreamReader. Вместо этого нам придется реализовать обработку потока самостоятельно, чтобы иметь возможность работать исключительно с StringBuilder. Это значительно повышает производительность, по крайней мере, в отношении распределения памяти и, следовательно, скорости.
@Lodewijk Мне придется пересмотреть свой ответ, если ваш ключевой вывод относительно ожидаемой производительности заключается в том, что Regex медленнее. Я пытался подчеркнуть, что обычно Regex будет намного медленнее, чем простой поиск и замена с использованием StringBuilder (для не слишком сложных шаблонов), но из-за итерации записей словаря и повторного анализа StringBuilder входной записи foreach, Regex должно быть значительно быстрее. Я был бы рад, если бы вы подтвердили то заявление, которое я сделал по результатам профилирования. В конце концов, вариант с регулярным выражением работает довольно быстро.
@BionicCode да, я удивлен тем, насколько быстро работает regex.replace. Но поскольку для его использования вам нужно сбросить построитель строк в строку, я не думаю, что в моем случае это понадобится.
@Lodewijk Вы имеете в виду, что не хотите использовать регулярное выражение или поиск и замену StringBuilder? Я думаю, что в этом специальном сценарии регулярное выражение работает быстрее, за исключением того, что вы хотите выполнить поиск по одному ключевому слову. Тогда StringBuilder должен работать быстрее.
Итак, я сравнил это. String.Replace работает быстрее, если чувствителен к регистру, но сильно замедляется при использовании IgnoreCase. Regex.Replace работает почти так же быстро, но выделяет больше памяти в куче. Потоковая передача через скользящий буфер и замена по ходу работы медленнее, чем Regex, но быстрее, чем String.Replace, а выделение памяти намного меньше. Поэтому мне нужно принять решение между памятью и скоростью :D
@Lodewijk Интересно. Вы также тестировали StringBuilder.Replace? Когда вы работаете с потоками, у вас есть возможность работать с массивами символов, чтобы полностью избежать выделения строк. StringBuilder должен играть важную роль, поскольку он представляет собой высокооптимизированный тип, с которым вам придется конкурировать при реализации поиска и замены строк вручную. Поиск в буфере сильно зависит от деталей реализации. Я очень сомневаюсь, что эта версия быстрее, чем строка. Замените на полном вводе, как вы сказали. Для меня это не имеет смысла.
@Lodewijk Что касается принятия решений: на мой взгляд, оба фактора стоимости, память и скорость, в целом не имеют значения. Если вы не работаете на платформе с небольшим объемом памяти (например, микроконтроллер) или в контексте обработки данных в реальном времени, все, что имеет значение, — это пользовательский опыт (UX). При использовании приложения пользователь никогда не заботится о потреблении памяти (кроме оперативной памяти). Но пользователь очень не любит ждать.
@Lodewijk Это делает время/скорость важнейшим фактором с точки зрения UX, связанного с производительностью. Таким образом, накопление затрат на скорость является более важным и всегда более актуальным, поскольку в 21 веке память дешева и ее много. Я всегда предпочитал скорость памяти.
@Lodewijk Сложность ключа поиска также очень интересна. Например, заменить все проявления «мира» на «гармонию», но, например, если не составлять такие слова, как «мирный», то `string.Replace' становится сложным для эффективного использования. Проект теста должен охватывать такие сценарии, за исключением случаев, когда они не имеют отношения к вашему варианту использования.
почему бы не прочитать его через буфер, например. это: Learn.microsoft.com/en-us/dotnet/api/… возвращаемое int сигнализирует, сколько символов было прочитано