Лучший способ сократить строку 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;
    }
Другие вопросы по тегам