golang
January 12, 2021

Парсим базу лиц причастных к экстремистской деятельности, при помощи 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. Но к сожалению, автор пакета (к слову, огромное спасибо за его труд) давно не возвращался к нему и не вносил новых исправлений. Пока пул-реквесты ждут своей участи, пакет с изменениями описанными в этой и предыдущей статье, можно найти тут.