Если один и тот же ответ локально читается без проблем, а в конвейере на облачном Mac иногда превращается в нечитаемый текст, сначала проверьте границы декодирования. По сети приходят блоки байтов, а не готовые символы: три байта символа «中» могут поступить в трёх отдельных блоках. Преобразование каждого блока в строку по отдельности может превратить корректный текст в символы замены, даже если ни один вызов не завершится ошибкой. Ниже мы закрепим требования к декодированию в отдельном Swift-скрипте, не полагаясь на случайное разбиение данных в реальной сети.
Сначала определим контракт байтового потока
На входе допускается только UTF-8, а на выходе — только полностью декодированные скалярные значения Unicode. Пустой блок не меняет состояние, незавершённая последовательность сохраняется до следующего блока, а её наличие при завершении потока считается ошибкой. Отсутствие текста на выходе в данный момент не означает, что соединение закрыто.
Здесь мы строго отклоняем некорректные данные: одиночные байты продолжения, избыточные кодировки, кодировки суррогатов и последовательности за пределами диапазона Unicode недопустимы. Замена некорректных последовательностей символом замены уместна в некоторых сценариях отображения, но её нельзя незаметно применять к полям протокола, конфигурации или сохраняемым записям.
Границы сетевых блоков, границы символов и границы сообщений прикладного протокола — три разные вещи. Успешное декодирование UTF-8 ещё не означает, что получена целая строка или полное сообщение.
Если протокол объявляет другую кодировку, такие данные нужно отклонить на входе либо передать отдельному, явно заданному слою преобразования. Нельзя угадывать кодировку, последовательно пробуя разные варианты. Скрипт также не удаляет автоматически маркер порядка байтов: допустим ли он, решает вышестоящий уровень.
Реализуем инкрементальный декодер с ограниченным состоянием
Сохраните следующий код в файл utf8-contract.swift. Декодер хранит только текущий незавершённый символ: между вызовами остаётся не более трёх байтов. При этом объём вывода каждого вызова по-прежнему зависит от размера входного блока. После ошибки экземпляр нужно отбросить и больше не передавать ему данные.
import Foundation
enum DecodeFailure: Error {
case invalidByte
case truncated
}
struct StrictUTF8 {
private var pending: [UInt8] = []
private var expected = 0
mutating func push(_ bytes: [UInt8]) throws -> String {
var output: [UInt8] = []
for byte in bytes {
if pending.isEmpty {
switch byte {
case 0x00...0x7F:
output.append(byte)
continue
case 0xC2...0xDF:
expected = 2
case 0xE0...0xEF:
expected = 3
case 0xF0...0xF4:
expected = 4
default:
throw DecodeFailure.invalidByte
}
pending.append(byte)
continue
}
var allowed: ClosedRange<UInt8> = 0x80...0xBF
if pending.count == 1 {
switch pending[0] {
case 0xE0: allowed = 0xA0...0xBF
case 0xED: allowed = 0x80...0x9F
case 0xF0: allowed = 0x90...0xBF
case 0xF4: allowed = 0x80...0x8F
default: break
}
}
guard allowed.contains(byte) else {
throw DecodeFailure.invalidByte
}
pending.append(byte)
if pending.count == expected {
output.append(contentsOf: pending)
pending.removeAll(keepingCapacity: true)
expected = 0
}
}
guard let text = String(bytes: output, encoding: .utf8) else {
throw DecodeFailure.invalidByte
}
return text
}
func finish() throws {
guard pending.isEmpty else {
throw DecodeFailure.truncated
}
}
}
Первый байт определяет длину последовательности, для второго диапазон допустимых значений сужается, а остальные проверяются на принадлежность диапазону байтов продолжения. Так кодировки суррогатов и значений за пределами Unicode выявляются ещё до преобразования в строку. В реализации нет автоматического восстановления: ASCII-байты после некорректного байта тоже не считаются началом нового достоверного сообщения.
Переберём разбиения вместо повторных запросов
Добавьте следующие тесты. Для этого короткого образца перебор всех вариантов «разрезать или не разрезать» между соседними байтами даёт более полную проверку, чем единственное разбиение на две части. Дополнительно вставим пустые блоки, чтобы убедиться: они не сбрасывают сохранённое состояние.
let sample = "A中🙂e\u{301}"
let bytes = Array(sample.utf8)
for mask in 0..<(1 << (bytes.count - 1)) {
var decoder = StrictUTF8()
var chunk: [UInt8] = []
var result = ""
for index in bytes.indices {
chunk.append(bytes[index])
let cut = index == bytes.count - 1
|| (mask & (1 << index)) != 0
if cut {
result += try decoder.push(chunk)
result += try decoder.push([])
chunk.removeAll(keepingCapacity: true)
}
}
try decoder.finish()
precondition(Array(result.utf8) == bytes)
}
Здесь сравниваются байты UTF-8, чтобы сравнение строк с учётом канонической эквивалентности не скрыло изменение содержимого. Комбинируемый знак ударения также напоминает: полное скалярное значение не обязательно является целым символом с точки зрения пользователя. Декодер может сначала выдать букву, а затем комбинируемую метку. Если интерфейсу нужны операции над графемами, это следует обрабатывать на более высоком уровне.
Число вариантов разбиения растёт экспоненциально с длиной образца, поэтому полный перебор подходит только для коротких фиксированных тестовых векторов. Для длинных текстов используйте разбиения, сгенерированные с фиксированным начальным значением генератора случайных чисел, и сохраняйте последовательность длин блоков при сбое.
Проверим отказные сценарии утверждениями
Успешная обработка корректного текста доказывает лишь половину необходимого. Добавьте эти векторы: они проверяют одиночный байт продолжения, избыточную кодировку, кодировку суррогата, выход за верхнюю границу Unicode, неверный байт продолжения и обрыв в конце потока. Каждый вход передаётся по одному байту, чтобы разбиение на блоки не позволяло обойти проверку.
let invalid: [[UInt8]] = [
[0x80],
[0xC0, 0xAF],
[0xED, 0xA0, 0x80],
[0xF4, 0x90, 0x80, 0x80],
[0xE4, 0x41],
[0xF0, 0x9F, 0x99]
]
for input in invalid {
var decoder = StrictUTF8()
var rejected = false
do {
for byte in input {
_ = try decoder.push([byte])
}
try decoder.finish()
} catch {
rejected = true
}
precondition(rejected)
}
Нужно также добавить проверки границ допустимых диапазонов, например четырёхбайтовую последовательность для максимального допустимого скалярного значения. Не используйте наличие символа замены в строке как единственный критерий: этот символ может законно присутствовать в исходных данных. Строгое декодирование должно принимать решение по исходным байтам.
Встроим проверку в конвейер и сохраним границы ошибок
Выполните команду в выбранном окружении инструментов командной строки Xcode:
xcrun swift utf8-contract.swift
Нормальное завершение означает, что проверка пройдена; выброшенная ошибка или невыполненное утверждение должны блокировать последующие задачи. При полноценной интеграции перенесите декодер в общий исходный код, а тестовые векторы — в тестовый таргет. Отдельный скрипт удобен для первичной проверки алгоритма, но не заменяет сборку и выполнение реального iOS-таргета.
У каждого соединения должен быть собственный экземпляр декодера, а вызовы должны выполняться последовательно, в порядке получения данных. При переподключении создавайте новый экземпляр: нельзя приклеивать половину символа из старого соединения к началу нового ответа. При отмене состояние также нужно отбросить, не вызывая путь успешного завершения.
Наконец, проверьте потребителя данных: вывод отдельных блоков должен попадать только во временный буфер. Сохранять данные или запускать действия можно лишь после получения полного сообщения прикладного протокола и успешной проверки. Если первая часть уже записана в базу, а некорректная кодировка обнаружилась только во второй, даже строгое декодирование не отменит побочные эффекты автоматически. Для диагностики достаточно записывать смещение в байтах, длину блока и категорию ошибки; не следует по умолчанию отправлять полное содержимое ответа. Тогда критерием приёмки станет не просто отсутствие нечитаемого текста, а независимость результата от разбиения на блоки и невозможность принять ошибку за успешную доставку.
Часто задаваемые вопросы
Можно ли превращать каждый блок Data в отдельную строку?
Нет. Граница блока может пройти внутри символа UTF-8. Строгое преобразование завершится ошибкой, а допускающее ошибки может вставить символ замены. Неполную последовательность нужно сохранить.
Можно ли отбросить оставшиеся байты в конце потока?
Нет, если требуется полный текст UTF-8. Это признак усечения, и завершение декодирования должно вернуть ошибку. Восстановление допускается только по отдельному явному правилу протокола.
Выделенный Mac для сборок вашей команды
Выберите выделенный физический Mac mini на Apple Silicon M4 и используйте собственный набор инструментов и настройки runner. Оплата доступна за день, неделю, месяц или квартал. Актуальная доступность отображается в панели управления в реальном времени.
Арендовать узел для сборки