Лучший способ сократить строку UTF8 на основе длины байта
Недавний проект призвал импортировать данные в базу данных Oracle. Это будет приложение C# .Net 3.5, и я использую библиотеку соединений Oracle.DataAccess для обработки фактической вставки.
Я столкнулся с проблемой, когда я получаю это сообщение об ошибке при вставке определенного поля:
ORA-12899 Слишком большое значение для столбца X
я использовал Field.Substring(0, MaxLength);
но все равно получил ошибку (хотя и не для каждой записи).
Наконец я увидел то, что должно было быть очевидным, моя строка была в формате ANSI, а поле было UTF8. Его длина определяется в байтах, а не в символах.
Это подводит меня к моему вопросу. Каков наилучший способ обрезать мою строку, чтобы исправить MaxLength?
Мой код подстроки работает по длине символа. Существует ли простая функция C#, которая может разумно обрезать строку UT8 по длине в байтах (т.е. не обрабатывать половину символа)?
8 ответов
Вот два возможных решения - однострочный LINQ, обрабатывающий ввод слева направо и традиционный for
цикл обработки ввода справа налево. Какое направление обработки быстрее, зависит от длины строки, разрешенной длины байта, а также от количества и распределения многобайтовых символов, и трудно дать общее предложение. Решение между LINQ и традиционным кодом у меня, вероятно, дело вкуса (или, может быть, скорости).
Если скорость имеет значение, можно подумать о том, чтобы просто накапливать длину в байтах каждого символа до достижения максимальной длины вместо расчета длины в байтах всей строки в каждой итерации. Но я не уверен, что это сработает, потому что я недостаточно хорошо знаю кодировку UTF-8. Я могу теоретически представить, что длина строки в байтах не равна сумме длин всех символов в байтах.
public static String LimitByteLength(String input, Int32 maxLength)
{
return new String(input
.TakeWhile((c, i) =>
Encoding.UTF8.GetByteCount(input.Substring(0, i + 1)) <= maxLength)
.ToArray());
}
public static String LimitByteLength2(String input, Int32 maxLength)
{
for (Int32 i = input.Length - 1; i >= 0; i--)
{
if (Encoding.UTF8.GetByteCount(input.Substring(0, i + 1)) <= maxLength)
{
return input.Substring(0, i + 1);
}
}
return String.Empty;
}
Я думаю, что мы можем добиться большего успеха, чем наивный подсчет общей длины строки при каждом добавлении. LINQ - это круто, но он может случайно поощрить неэффективный код. Что если бы я хотел получить первые 80000 байтов гигантской строки UTF? Это много ненужных подсчетов. "У меня есть 1 байт. Теперь у меня есть 2. Теперь у меня есть 13... Теперь у меня есть 52 384..."
Это глупо. Большую часть времени, по крайней мере, в Англии мы можем сократить именно nth
байт. Даже на другом языке мы находимся менее чем в 6 байтах от хорошей точки отсечения.
Итак, я собираюсь начать с предложения @Oren, которое заключается в отключении старшего бита значения символа UTF8. Давайте начнем с резки прямо на n+1th
байт, и используйте трюк Орена, чтобы выяснить, нужно ли нам сократить несколько байт раньше.
Три возможности
Если первый байт после обрезки имеет 0
в начальном бите я знаю, что обрезаю точно перед символом в один байт (обычный ASCII) и могу обрезать чисто.
Если у меня есть 11
после обрезки следующий байт после обрезки является началом многобайтового символа, так что это также хорошее место для обрезки!
Если у меня есть 10
однако я знаю, что я нахожусь в центре многобайтового символа, и мне нужно вернуться, чтобы проверить, где он действительно начинается.
То есть, хотя я хочу вырезать строку после n-го байта, если этот n+1-й байт находится в середине многобайтового символа, вырезание создаст недопустимое значение UTF8. Мне нужно сделать резервную копию, пока я не доберусь до той, которая начинается 11
и вырезать прямо перед этим.
Код
Примечания: я использую такие вещи, как Convert.ToByte("11000000", 2)
так что легко сказать, какие биты я маскирую (немного больше о битовой маскировке здесь). Короче говоря, я &
чтобы вернуть то, что находится в первых двух битах байта и вернуть 0
для отдыха. Затем я проверяю XX
от XX000000
чтобы увидеть, если это 10
или же 11
где уместно.
Сегодня я узнал, что C# 6.0 на самом деле может поддерживать двоичные представления, что здорово, но пока мы будем продолжать использовать этот kludge, чтобы проиллюстрировать, что происходит.
PadLeft
это просто потому, что я чрезмерно беспокоюсь о выводе на консоль.
Итак, вот функция, которая сократит вас до строки, которая n
длина байта или наибольшее число меньше n
это заканчивается "полным" символом UTF8.
public static string CutToUTF8Length(string str, int byteLength)
{
byte[] byteArray = Encoding.UTF8.GetBytes(str);
string returnValue = string.Empty;
if (byteArray.Length > byteLength)
{
int bytePointer = byteLength;
// Check high bit to see if we're [potentially] in the middle of a multi-byte char
if (bytePointer >= 0
&& (byteArray[bytePointer] & Convert.ToByte("10000000", 2)) > 0)
{
// If so, keep walking back until we have a byte starting with `11`,
// which means the first byte of a multi-byte UTF8 character.
while (bytePointer >= 0
&& Convert.ToByte("11000000", 2) != (byteArray[bytePointer] & Convert.ToByte("11000000", 2)))
{
bytePointer--;
}
}
// See if we had 1s in the high bit all the way back. If so, we're toast. Return empty string.
if (0 != bytePointer)
{
returnValue = Encoding.UTF8.GetString(byteArray, 0, bytePointer); // hat tip to @NealEhardt! Well played. ;^)
}
}
else
{
returnValue = str;
}
return returnValue;
}
Я изначально написал это как расширение строки. Просто добавьте обратно this
до string str
чтобы вернуть его в формат расширения, конечно. Я удалил this
так что мы могли бы просто ударить метод в Program.cs
в простом консольном приложении для демонстрации.
Тест и ожидаемый результат
Вот хороший тестовый пример, с выводом, который он создает ниже, написанным в ожидании Main
метод в простом консольном приложении Program.cs
,
static void Main(string[] args)
{
string testValue = "12345“”67890”";
for (int i = 0; i < 15; i++)
{
string cutValue = Program.CutToUTF8Length(testValue, i);
Console.WriteLine(i.ToString().PadLeft(2) +
": " + Encoding.UTF8.GetByteCount(cutValue).ToString().PadLeft(2) +
":: " + cutValue);
}
Console.WriteLine();
Console.WriteLine();
foreach (byte b in Encoding.UTF8.GetBytes(testValue))
{
Console.WriteLine(b.ToString().PadLeft(3) + " " + (char)b);
}
Console.WriteLine("Return to end.");
Console.ReadLine();
}
Вывод следующий. Обратите внимание, что "умные цитаты" в testValue
в UTF8 длиной три байта (хотя, когда мы записываем символы в консоль в ASCII, она выводит тупые кавычки). Также обратите внимание на ?
вывод s для второго и третьего байтов каждой умной кавычки в выводе.
Первые пять персонажей нашего testValue
являются однобайтовыми в UTF8, поэтому значения 0-5 байтов должны быть 0-5 символов. Затем у нас есть трехбайтовая умная цитата, которая не может быть включена полностью до 5 + 3 байтов. Конечно же, мы видим, что выскочить на призыв к 8
Наша следующая умная цитата выскакивает на 8 + 3 = 11, и затем мы возвращаемся к однобайтовым символам до 14.
0: 0::
1: 1:: 1
2: 2:: 12
3: 3:: 123
4: 4:: 1234
5: 5:: 12345
6: 5:: 12345
7: 5:: 12345
8: 8:: 12345"
9: 8:: 12345"
10: 8:: 12345"
11: 11:: 12345""
12: 12:: 12345""6
13: 13:: 12345""67
14: 14:: 12345""678
49 1
50 2
51 3
52 4
53 5
226 â
128 ?
156 ?
226 â
128 ?
157 ?
54 6
55 7
56 8
57 9
48 0
226 â
128 ?
157 ?
Return to end.
Так что это довольно забавно, и я как раз перед пятилетней годовщиной вопроса. Хотя описание битов в Орене имело небольшую ошибку, это именно та хитрость, которую вы хотите использовать. Спасибо за вопрос; аккуратный.
Все остальные ответы, кажется, упускают тот факт, что эта функциональность уже встроена в.NET, в Encoder
учебный класс. Для бонусных баллов этот подход также будет работать для других кодировок.
public static String LimitByteLength(string input, int maxLength)
{
if (string.IsNullOrEmpty(input) || Encoding.UTF8.GetByteLength(input) <= maxLength)
{
return message;
}
var encoder = Encoding.UTF8.GetEncoder();
byte[] buffer = new byte[maxLength];
char[] messageChars = message.ToCharArray();
encoder.Convert(
chars: messageChars,
charIndex: 0,
charCount: messageChars.Length,
bytes: buffer,
byteIndex: 0,
byteCount: buffer.Length,
flush: false,
charsUsed: out int charsUsed,
bytesUsed: out int bytesUsed,
completed: out bool completed);
// I don't think we can return message.Substring(0, charsUsed)
// as that's the number of UTF-16 chars, not the number of codepoints
// (think about surrogate pairs). Therefore I think we need to
// actually convert bytes back into a new string
return Encoding.UTF8.GetString(bytes, 0, bytesUsed);
}
Укороченная версия ответа Раффина. Использует преимущества конструкции UTF8:
public static string LimitUtf8ByteCount(this string s, int n)
{
// quick test (we probably won't be trimming most of the time)
if (Encoding.UTF8.GetByteCount(s) <= n)
return s;
// get the bytes
var a = Encoding.UTF8.GetBytes(s);
// if we are in the middle of a character (highest two bits are 10)
if (n > 0 && ( a[n]&0xC0 ) == 0x80)
{
// remove all bytes whose two highest bits are 10
// and one more (start of multi-byte sequence - highest bits should be 11)
while (--n > 0 && ( a[n]&0xC0 ) == 0x80)
;
}
// convert back to string (with the limit adjusted)
return Encoding.UTF8.GetString(a, 0, n);
}
Если байт UTF-8 имеет нулевой бит старшего разряда, это начало символа. Если его старший бит равен 1, он находится в "середине" символа. Способность обнаружить начало персонажа была явной целью разработки UTF-8.
Проверьте раздел Описание статьи в Википедии для более подробной информации.
Есть ли причина, по которой вам нужно объявить столбец базы данных в байтах? Это значение по умолчанию, но оно не особенно полезно по умолчанию, если набор символов базы данных имеет переменную ширину. Я бы настоятельно предпочел объявить столбец с точки зрения символов.
CREATE TABLE length_example (
col1 VARCHAR2( 10 BYTE ),
col2 VARCHAR2( 10 CHAR )
);
Это создаст таблицу, в которой COL1 будет хранить 10 байтов данных, а col2 будет хранить данные объемом 10 символов. Семантика длины символов имеет гораздо больше смысла в базе данных UTF8.
Предполагая, что вы хотите, чтобы все таблицы, которые вы создаете, использовали семантику длины символа по умолчанию, вы можете установить параметр инициализации NLS_LENGTH_SEMANTICS
ЧАР. В этот момент любые создаваемые вами таблицы будут по умолчанию использовать семантику длины символа, а не семантику длины байта, если вы не укажете CHAR или BYTE в длине поля.
После Oren Trutner вот еще два решения проблемы:
здесь мы подсчитываем количество байтов, которые нужно удалить из конца строки, в соответствии с каждым символом в конце строки, поэтому мы не оцениваем всю строку в каждой итерации.
string str = "朣楢琴执执 瑩浻牡楧硰执执獧浻牡楧敬瑦 瀰 絸朣杢执獧扻捡杫潲湵 潣"
int maxBytesLength = 30;
var bytesArr = Encoding.UTF8.GetBytes(str);
int bytesToRemove = 0;
int lastIndexInString = str.Length -1;
while(bytesArr.Length - bytesToRemove > maxBytesLength)
{
bytesToRemove += Encoding.UTF8.GetByteCount(new char[] {str[lastIndexInString]} );
--lastIndexInString;
}
string trimmedString = Encoding.UTF8.GetString(bytesArr,0,bytesArr.Length - bytesToRemove);
//Encoding.UTF8.GetByteCount(trimmedString);//get the actual length, will be <= 朣楢琴执执 瑩浻牡楧硰执执獧浻牡楧敬瑦 瀰 絸朣杢执獧扻捡杫潲湵 潣潬昣昸昸慢正
И еще более эффективное (и обслуживаемое) решение: получить строку из массива байтов в соответствии с желаемой длиной и вырезать последний символ, потому что он может быть поврежден
string str = "朣楢琴执执 瑩浻牡楧硰执执獧浻牡楧敬瑦 瀰 絸朣杢执獧扻捡杫潲湵 潣"
int maxBytesLength = 30;
string trimmedWithDirtyLastChar = Encoding.UTF8.GetString(Encoding.UTF8.GetBytes(str),0,maxBytesLength);
string trimmedString = trimmedWithDirtyLastChar.Substring(0,trimmedWithDirtyLastChar.Length - 1);
Единственным недостатком второго решения является то, что мы могли бы вырезать совершенно новый последний символ, но мы уже обрезаем строку, поэтому она может соответствовать требованиям.
Спасибо Shhade Slman, который подумал о втором решении
Это еще одно решение, основанное на бинарном поиске:
public string LimitToUTF8ByteLength(string text, int size)
{
if (size <= 0)
{
return string.Empty;
}
int maxLength = text.Length;
int minLength = 0;
int length = maxLength;
while (maxLength >= minLength)
{
length = (maxLength + minLength) / 2;
int byteLength = Encoding.UTF8.GetByteCount(text.Substring(0, length));
if (byteLength > size)
{
maxLength = length - 1;
}
else if (byteLength < size)
{
minLength = length + 1;
}
else
{
return text.Substring(0, length);
}
}
// Round down the result
string result = text.Substring(0, length);
if (size >= Encoding.UTF8.GetByteCount(result))
{
return result;
}
else
{
return text.Substring(0, length - 1);
}
}
public static string LimitByteLength3(string input, Int32 maxLenth)
{
string result = input;
int byteCount = Encoding.UTF8.GetByteCount(input);
if (byteCount > maxLenth)
{
var byteArray = Encoding.UTF8.GetBytes(input);
result = Encoding.UTF8.GetString(byteArray, 0, maxLenth);
}
return result;
}