Парсим базу лиц причастных к экстремистской деятельности, при помощи Go
В моей предыдущей статье, я рассказывал о том, как устроена база лиц, причастных к экстремистской деятельности и какие подводные камни она скрывает. Но обозначить проблему мало, её нужно ещё и решить. Этим мы сегодня и займёмся, а в качестве языка будем использовать Go.
Глосарий
Ниже я обозначу термины, которые будут использоваться, применительно к рассматриваемой базе.
База террористов — Перечень организаций и физических лиц, в отношении которых имеются сведения об участии в экстремистской деятельности.
Запись — это совокупность строк, объединённых одним значением поля “NUMBER”.
Статические поля — простейший вид полей, не имеющих строгого набора значений. Не являются представлениями даты, и не могут быть разделены на несколько строк. Значение в первой строке записи считается валидным значением статического поля.
Перечисляемые (enum) поля — поля с фиксированным набором допустимых значений. Для такого поля, валидным может считаться только значение из определённого для него диапазона.
Текстовые поля — поля, чьи значения могут быть разбиты на несколько строк и для того чтобы собрать запись, значения полей из отдельных строк, нужно объединить.
Что узнаем?
Я опишу мой вариант парсинга файла базы террористов, учитывая его особенности, притом стараясь сохранять код максимально читаемым (применим пакет reflect). А также расскажу о ещё одной ошибке, обнаруженной в исходном коде пакета godbf.
Чего хотим?
Как результат, я хочу получить пакет на языке Gо, который обладает следующими свойствами:
- позволяет читать данные по записям, а не по строкам;
- записи возвращаются упорядоченно (чем меньше значение в поле “NUMBER”, тем раньше запись будет отдана пакетом);
- чтение записей происходит асинхронно, результаты записываются в канал, размер буфера канала настраивается клиентским кодом;
- пакет выполняет лишь необходимую работу, не пытаясь предугадать все потребности клиентского кода.
Решение
Собственно далее мы будем рассматривать реализацию вот этого пакета.
Сразу скажу, что рассматривать каждую структуру и каждый метод мы не будем, да это и не имеет смысла. Задача этого текста рассказать общую концепцию и показать практическое решение задачи преобразования набора строк в валидную запись.
Решение будет состоять из следующих шагов:
- подготовка вспомогательных данных;
- описание структуры для хранения записи;
- сборка записи (задание статических, перечисляемых, текстовых полей и полей, содержащих дату).
Подготавливаем данные
Так как строки записи разбросаны по файлу и порядок записей не гарантирован, то для обеспечения эффективного чтения и соблюдения порядка следования записей, нам нужно создать некую вспомогательную структуру.
Это будет карта, где ключом будет номер записи, а значением слайс структур, содержащих индекс строки и значение поля “ROW_ID”. А также слайс,содержащий уникальные номера записей в порядке возрастания номера.
В коде ниже это поля rowDataMap и rowNumbers.
type rowData struct { index int rowID uint64 }
type rowDataMap map[uint64][]rowData
type TerReader struct { dbfTable dbfTable rowDataMap rowDataMap rowNumbers []uint64 }
Если предположить, что наш файл содержит всего две записи, одна из которых состоит из двух строк, а вторая из одной, то значения полей rowDataMap и rowNumbers могут выглядеть так:
rowDataMap: { 2: [ { index: 3, rowID: 3 }, ], 1: [ { index: 1, rowID: 1 }, { index: 2, rowID: 2 } ], }, rowNumbers: [1, 2]
Такой подход позволит нам совершить всего один проход по файлу, занять немного памяти и получить полное представление о всех имеющихся в файле записях, нужно лишь собрать их и выдать результат.
Описываем структуру для хранения записи
type Row struct { Number string `tr_col:"NUMBER" tr_type:"static"` Terror string `tr_col:"TERROR" tr_type:"enum"` Tu string `tr_col:"TU" tr_type:"enum"` Nameu string `tr_col:"NAMEU" tr_type:"text"` Descript string `tr_col:"DESCRIPT" tr_type:"text"` Kodcr string `tr_col:"KODCR" tr_type:"static"` Kodcn string `tr_col:"KODCN" tr_type:"static"` Amr string `tr_col:"AMR" tr_type:"text"` Address string `tr_col:"ADRESS" tr_type:"text"` Kd string `tr_col:"KD" tr_type:"enum"` Sd string `tr_col:"SD" tr_type:"static"` Rg string `tr_col:"RG" tr_type:"static"` Nd string `tr_col:"ND" tr_type:"static"` Vd string `tr_col:"VD" tr_type:"static"` Gr *time.Time `tr_col:"GR" tr_type:"date"` Yr string `tr_col:"YR" tr_type:"static"` Mr string `tr_col:"MR" tr_type:"text"` CbDate *time.Time `tr_col:"CB_DATE" tr_type:"date"` CeDate *time.Time `tr_col:"CE_DATE" tr_type:"date"` Director string `tr_col:"DIRECTOR" tr_type:"text"` Founder string `tr_col:"FOUNDER" tr_type:"text"` RowID string `tr_col:"ROW_ID" tr_type:"static"` Terrtype string `tr_col:"TERRTYPE" tr_type:"text"` }
Главное, на что здесь стоит обратить внимание — это теги “tr_col” (имя столбца в файле) и “tr_type” (тип поля в файле (по нашей классификации к типу поля в файле отношения не имеет)). Эти теги помогут в заполнении структуры данными. По тегам мы будет определять тип поля и выбирать соответствующий метод для его заполнения.
Собираем запись
Вернёмся к полям rowDataMap и rowNumbers нашей основной структуры TerReader, они являются отправной точкой.
Мы будем перебирать слайс rowNumbers и использовать полученный из него номер записи, для извлечения из карты rowDataMap, структуры rowData, содержащей индекс строки файла (index) и значение ROW_ID (rowID). После чего, структура rowData будет передана методу buildRecord для сборки записи.
В упрощённом виде это выглядит вот так:
for _, number := range tr.rowNumbers { rowDataSlice := tr.rowDataMap[number] row, _ := tr.buildRecord(rowDataSlice) }
Метод buildRecord отсортирует данные о строках ([]rowData) в порядке возрастания значения rowID, а затем, используя пакет reflect, будет перебирать поля структуры Row и устанавливать для них значения.
Тут нам и помогут проставленные в ней теги. Тег “tr_col” устанавливает соответствие между полями структуры и полями файла, а тег “tr_type” поможет выбрать подходящий метод, который и вернёт финальное значение для поля.
func (tr *TerReader) buildRecord(rowDataSlice []rowData) (*Row, error) { sort.SliceStable(rowDataSlice, func(i, j int) bool { return rowDataSlice[i].rowID < rowDataSlice[j].rowID }) row := &Row{} val := reflect.ValueOf(row).Elem() for i := 0; i < val.NumField(); i++ { valueField := val.Field(i) typeField := val.Type().Field(i) fieldName := typeField.Tag.Get("tr_col") fieldType := typeField.Tag.Get("tr_type") switch fieldType { case "static": val, _ := tr.dbfTable.FieldValueByName(rowDataSlice[0].index, fieldName) valueField.SetString(val) case "enum": val, _ := tr.getEnumValue(fieldName, rowDataSlice) valueField.SetString(val) case "date": val, _ := tr.getDateValue(fieldName, rowDataSlice[0].index) valueField.Set(reflect.ValueOf(val)) case "text": val, _ := tr.getTextValue(fieldName, rowDataSlice) valueField.SetString(val) } } return row, nil }
Обработка ошибок опущена для простоты
Статические поля (static)
Тут всё просто. Берём значение из первой строки записи и устанавливаем его в поле структуры.
Перечисляемые поля (enum)
Для полей этого типа, в качестве значения поля структуры, допустимы только значения, входящие в соответствующий полю диапазон.
func getEnum(fieldName string) ([]string, error) { switch fieldName { case "TERROR": return []string{"0", "1"}, nil case "TU": return []string{"1", "2", "3"}, nil case "KD": return []string{"0", "01", "02", "03", "04"}, nil } return []string{}, fmt.Errorf("not support field name '%s'", fieldName) }
Если среди всех строк записи не будет найдено подходящее значение поля, мы вернём для записи ошибку, а дальнейшая обработка файла будет остановлена.
enumValues, _ := getEnum(fieldName) isInclude := func(el string, slice []string) bool { // Is the value in the range? } for _, data := range rowDataSlice { val, _ := tr.dbfTable.FieldValueByName(data.index, fieldName) if isInclude(val, enumValues) { return val, nil } } return "", fmt.Errorf("can not find a suitable value for '%s'", fieldName)
Поля содержащие дату (date)
Исследовав файл, мы знаем, что все даты в полях написаны в формате “YYYYMMDD”, а в принятом в Go формате:
const dateFormat = "20060102"
Его и будем использовать для непустых полей
func (tr *TerReader) getDateValue(fieldName string, rowIndex int) (*time.Time, error) { val, _ := tr.dbfTable.FieldValueByName(rowIndex, fieldName) if val == "" { return nil, nil } t, err := time.Parse(dateFormat, val) return &t, err }
Текстовые поля (text)
Этот тип полей доставлял больше всего проблем, но после выявления закономерностей и с ним справиться не трудно.
const needTrailingSpaceCharNum = 253 func (tr *TerReader) getTextValue(fieldName string, rowDataSlice []rowData) (string, error) { var text string var lastIncludedStrLen int for _, data := range rowDataSlice { val, err := tr.dbfTable.FieldValueByName(data.index, fieldName) if strings.Contains(text, val) || val == "" { continue } leadingChar := "" if lastIncludedStrLen == needTrailingSpaceCharNum { leadingChar = " " } text += leadingChar + val lastIncludedStrLen = len(val) } return text, nil }
Обрабатывая строку за строкой, мы проверяем:
- есть ли полученное значение поля в результирующем тексте (так мы избегаем дубликатов)? Если да, пропускаем это значение;
- является ли текущее значение пустой строкой? Если да, пропускаем это значение;
- равна ли длина предыдущей добавленной строки 253-м символам? Максимальная длина текстового поля 254, это значит, что если текущее значение не пустое, а предыдущая строка имеет длину 253 символа, мы должны восполнить потерянный в предшествующей строке пробел, справа (это особенность чтения полей из DBF файла, я говорил об этом тут). Это делается при помощи установки в переменную leadingChar пробельного символа.
Вот и всё.
Ошибка в пакете godbf
При написании тестов для пакета terreader выяснилось, что при передаче методу NewTerReader пути к несуществующему файлу, метод не вернёт ошибку.
Как стало понятно позже, дело было в методе NewFromFile пакета godbf. Взгляните на оригинальный код метода:
func NewFromFile(fileName string, fileEncoding string) (table *DbfTable, err error) { if s, err := readFile(fileName); err == nil { return createDbfTable(s, fileEncoding) } return }
Возможно вы уже увидели в чём тут беда, но если нет — поясню: Go позволяет именовать возвращаемые значения. Если возвращаемые значения именованны, то мы можем просто задать значения соответствующих переменных в теле функции и не заботиться о том, чтобы вернуть их явно.
func HelloStr()(str string, err error) { str := "Hello" err := nil return } str, _ := HelloStr() fmt.Println(str) // Hello
Именно на этот механизм и рассчитывал автор пакета. И всё бы получилось, но автор обрабатывает ошибку внутри блока if, а это значит, что переменная err будет создана внутри этого блока и не выйдет за его пределы. Переменная err описанная как возвращаемое значение и переменная err внутри условия — это две разные переменные.
Исправить ситуацию поможет присвоение значения переменной err за пределами блока if. Заодно откажемся и от ненужного именования возвращаемых значений.
func NewFromFile(fileName string, fileEncoding string) (*DbfTable, error) { s, err := readFile(fileName) if err != nil { return nil, err } return createDbfTable(s, fileEncoding) }
Заключение
Написание этой статьи помогло мне ещё раз переосмыслить принятые при разработке пакета решения и взглянуть на них со стороны. Я надеюсь что вам статья тоже была полезна и поможет написать собственное решение или с большим комфортом использовать моё, если оно вам приглянулось.
Также хочу отметить, что пакетов для чтения DBF файлов на Go я нашёл всего два, и godbf наиболее популярный. Поэтому поиск и исправление в нём ошибок полезно всем, кто сталкивается в работе с DBF файлами.
В процессе написания пакета, была найдена ошибка в пакете godbf. Но к сожалению, автор пакета (к слову, огромное спасибо за его труд) давно не возвращался к нему и не вносил новых исправлений. Пока пул-реквесты ждут своей участи, пакет с изменениями описанными в этой и предыдущей статье, можно найти тут.