Парсим базу лиц причастных к экстремистской деятельности, при помощи Go

Алексей Коноваленко
rnds
Published in
7 min readJan 12, 2021

В моей предыдущей статье, я рассказывал о том, как устроена база лиц причастных к экстремистской деятельности и какие подводные камни она скрывает. Но обозначить проблему мало, её нужно ещё и решить. Этим мы сегодня и займёмся, а в качестве языка будем использовать Go.

Глосарий

Ниже я обозначу термины, которые будут использоваться, применительно к рассматриваемой базе.

База террористов — Перечень организаций и физических лиц, в отношении которых имеются сведения об участии в экстремистской деятельности.

Запись — это совокупность строк, объеденённых одним значением поля “NUMBER”.

Статические поля — простейший вид полей, не имеющих строгого набора значений. Не являются представлениями даты, и не могут быть разделены на несколько строк. Значение в первой строке записи считается валидным значением статического поля.

Перечисляемые (enum) поля — поля с фиксированным набором допустимых значений. Для такого поля, валидным может считаться только значение из определённого для него диапазона.

Текстовые поля — поля чьи значение могут быть разбиты на несколько строк и для того чтобы собрать запись, значения полей из отдельных строк, нужно объеденить.

Что узнаем?

Я опишу мой вариант парсинга файла базы террористов, учитывая его особенности, при том стараясь сохранять код максимально читаемым (применим пакет reflect). А так же расскажу об ещё одной ошибке, обнаруженной в исходном коде пакета godbf.

Чего хотим?

Как результат, я хочу получить пакет на языке Gо, который обладает следующими свойствами:

  • позволяет читать данные по записям, а не по строкам;
  • записи возвращаются упорядоченно (чем меньше значение в поле “NUMBER”, тем раньше запись будет отдана пакетом);
  • чтение записей происходит асинхронно, результаты записываются в канал, размер буфера канала настраивается клиентским кодом;
  • пакет выполняет лишь необходимую работу, не пытаясь предугадать все потребности клиентского кода.

Решение

Собственно далее мы будем рассматривать реализацию вот этого пакета.

Сразу скажу, что рассматривать каждую структуру и каждый метод мы не будем, да это и не имеет смысла. Задача этого текста рассказать общую концепцию и показать практическое решение задачи преобразования набора строк в валидную запись.

Решение будет состоять из следующих шагов:

  • подготовка вспомогательных данных;
  • описание структуры для хранения записи;
  • сборка записи (задание статических, перечеслимых, текстовых и полей содержащих дату).

Подготавливаем данные

Так как строки записи разбросаны по файлу и порядок записей не гарантирован, то для обеспечения эффективного чтения и соблюдения порядка следования записей, нам нужно создать некую вспомогательную структуру.

Это будет карта, где ключом будет номер записи, а значением слайс структур, содержащих индекс строки и значение поля “ROW_ID”. А также слайс,содержащий уникальные номера записей в порядке возрастания номера.

В коде ниже это поля rowDataMap и rowNumbers.

type rowData struct {
index int
rowID uint64
}
type rowDataMap map[uint64][]rowDatatype 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 = 253func (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. Но к сожалению, автор пакета (к слову, огромное спасибо за его труд) давно не возвращался к нему и не вносил новых исправлений. Пока пул-реквесты ждут своей участи, пакет с изменениями описанными в этой и предыдущей статье, можно найти тут.

--

--