Использование Powershell и Regex для анализа файлов полей фиксированной длины. Как заменить пустые группы захвата нулями? - PullRequest
2 голосов
/ 11 декабря 2019

Я использую сценарий PowerShell и Regex, чтобы превратить гигантские (> 1 ГБ) текстовые файлы фиксированной длины поля в импортируемые файлы с разделителями табуляции. Код очень быстрый. Мне нужно изменить некоторые из захваченных полей (скажем, 4-го, 6-го и 7-го полей) на 0, если после обрезки они пусты. Есть ли сверхбыстрый способ сделать это, скажем, как часть захвата регулярных выражений, не сильно замедляя этот процесс?

DATA

ID         FIRST_NAME              LAST_NAME          COLUMN_NM_TOO_LON5THCOLUMN
10000000001MINNIE                  MOUSE              COLUMN VALUE LONGSTARTS 


PROGRAM

$proc_yyyymm = '201912'
match_regex = '^(.{10})(.{10})(.{30})(.{30})(.{30})(.{4})(.{8})(.{10})(.{1})(.{15})(.{12})'

while ($line = $stream_in.ReadLine()) {

   if ($line -match $match_data_regex) {
      $new_line = "$proc_yyyymm`t" + ($Matches[1..($Matches.Count-1)].Trim() -join "`t")
      $stream_out.WriteLine($new_line)
   }
}

Ответы [ 3 ]

2 голосов
/ 12 декабря 2019

После внесения некоторых изменений в ваш код в демонстрационных целях ...

  • Усечение регулярного выражения в соответствии с данными примера
  • Изменение разделителя вывода (теперь $delimiter)до ,, поэтому результаты легко увидеть
  • Использование StringReader и StringWriter для ввода и вывода соответственно

... учитывая ...

$text = @'
ID         FIRST_NAME              LAST_NAME          COLUMN_NM_TOO_LON5THCOLUMN
10000000001MINNIE                  MOUSE              COLUMN VALUE LONGSTARTS   
10000000002PLUTO                                      COLUMN VALUE LONGSTARTS   
'@

... способ, которым вы предложили настроить текст совпадения для определенных индексов, будет выглядеть следующим образом ...

$proc_yyyymm = '201912'
$match_regex = '^(.{11})(.{24})(.{19})(.{17})(.{9})'

$delimiter = ','
$indicesToNormalizeToZero = ,2

$stream_in = New-Object -TypeName 'System.IO.StringReader' -ArgumentList $text
$stream_out = New-Object -TypeName 'System.IO.StringWriter'

while ($line = $stream_in.ReadLine()) {
    if ($line -match $match_regex) {
        $trimmedMatches = $Matches[1..($Matches.Count-1)].Trim()
        foreach ($index in $indicesToNormalizeToZero)
        {
            if ($trimmedMatches[$index] -eq '')
            {
                $trimmedMatches[$index] = '0'
            }
        }

        $new_line = "$proc_yyyymm$delimiter" + ($trimmedMatches -join $delimiter)
        $stream_out.WriteLine($new_line)
    }
}

$stream_out.ToString()

Альтернативой может быть использование [Regex]::Replace() метод . Это хорошо, когда вам нужно выполнить пользовательское преобразование для совпадения, которое не может быть выражено в подстановке регулярного выражения . По общему признанию, это может быть плохое соответствие, потому что вы сопоставляете целую строку вместо отдельных полей, поэтому в пределах соответствия вам нужно знать, какое поле какое.

$proc_yyyymm = '201912'
$match_regex = [Regex] '^(.{11})(.{24})(.{19})(.{17})(.{9})'
$match_evaluator = {
    param($match)

    # The first element of Groups contains the entire matched text; skip it
    $fields = $match.Groups `
        | Select-Object -Skip 1 `
        | ForEach-Object -Process {
            $field = $_.Value.Trim()
            if ($groupsToNormalizeToZero -contains $_.Name -and $field -eq '')
            {
                $field = '0'
            }

            return $field
        }

    return "$proc_yyyymm$delimiter" + ($fields -join $delimiter)
}

$delimiter = ','
# Replace with a HashSet/Hashtable for better lookup performance
$groupsToNormalizeToZero = ,'3'

$stream_in = New-Object -TypeName 'System.IO.StringReader' -ArgumentList $text
$stream_out = New-Object -TypeName 'System.IO.StringWriter'

while ($line = $stream_in.ReadLine()) {
    $new_line = $match_regex.Replace($line, $match_evaluator)

    # The original input string is returned if there was no match
    if (-not [Object]::ReferenceEquals($line, $new_line)) {
        $stream_out.WriteLine($new_line)
    }
}

$stream_out.ToString()

$match_evaluator является MatchEvaluator делегат , который вызывается для каждого успешного совпадения, найденного во входном тексте, в Replace() и возвращает все, что вы хотите, чтобы текст замены был. Внутри я делаю то же самое специфичное для индекса преобразование, сравнивая имя группы (которое будет индексом [String]) с известным списком ($groupsToNormalizeToZero);вместо этого вы могли бы использовать именованные группы, хотя я обнаружил, что это меняет порядок $match.Groups. Здесь могут быть более подходящие приложения [Regex]::Replace(), которые сейчас мне не встречаются.

В качестве альтернативы использованию регулярных выражений, поскольку их длины известны, вы можете извлечь поля непосредственно из $line, используя Substring() method ...

$proc_yyyymm = '201912'
$delimiter = ','

$stream_in = New-Object -TypeName 'System.IO.StringReader' -ArgumentList $text
$stream_out = New-Object -TypeName 'System.IO.StringWriter'

while ($line = $stream_in.ReadLine()) {
    $id =                $line.Substring( 0, 11).Trim()
    $firstName =         $line.Substring(11, 24).Trim()
    $lastName =          $line.Substring(35, 19).Trim()
    $columnNameTooLong = $line.Substring(54, 17).Trim()
    $fifthColumn =       $line.Substring(71,  9).Trim()

    if ($lastName -eq '')
    {
        $lastName = '0'
    }

    $new_line = $proc_yyyymm,$id,$firstName,$lastName,$columnNameTooLong,$fifthColumn -join $delimiter
    $stream_out.WriteLine($new_line)
}

$stream_out.ToString()

Еще лучше, поскольку длина каждой строки известна, вы можете избежать проверок новой строки ReadLine() и последующего выделения String, читаякаждая строка в виде блока Char с и извлечения оттуда полей.

function ExtractField($chars, $startIndex, $length, $normalizeIfFirstCharWhitespace = $false)
{
    # If the first character of a field is whitespace, assume the
    # entire field is as well to avoid a String allocation and Trim()
    if ($normalizeIfFirstCharWhitespace -and [Char]::IsWhiteSpace($chars[$startIndex])) {
        return '0'
    } else {
        # Create a String from the span of Chars at known boundaries and trim it
        return (New-Object -TypeName 'String' -ArgumentList ($chars, $startIndex, $length)).Trim()
    }
}

$proc_yyyymm = '201912'
$delimiter = ','

$stream_in = New-Object -TypeName 'System.IO.StringReader' -ArgumentList $text
$stream_out = New-Object -TypeName 'System.IO.StringWriter'

$lineLength = 82 # Assumes the last line ends with an \r\n and not EOF
$lineChars = New-Object -TypeName 'Char[]' -ArgumentList $lineLength

while (($lastReadCount = $stream_in.ReadBlock($lineChars, 0, $lineLength)) -gt 0)
{
    $id                = ExtractField $lineChars  0 11
    $firstName         = ExtractField $lineChars 11 24
    $lastName          = ExtractField $lineChars 35 19 $true
    $columnNameTooLong = ExtractField $lineChars 54 17
    $fifthColumn       = ExtractField $lineChars 71  9

    # Are all these method calls better or worse than a single WriteLine() and object allocation(s)?
    $stream_out.Write($proc_yyyymm)
    $stream_out.Write($delimiter)
    $stream_out.Write($id)
    $stream_out.Write($delimiter)
    $stream_out.Write($firstName)
    $stream_out.Write($delimiter)
    $stream_out.Write($lastName)
    $stream_out.Write($delimiter)
    $stream_out.Write($columnNameTooLong)
    $stream_out.Write($delimiter)
    $stream_out.WriteLine($fifthColumn)
}

$stream_out.ToString()

Поскольку ответ @ HAL9256 подтверждает, что функции PowerShell (очень!) медленные, способсделать то же самое без избыточного кода и без функций - определить набор дескрипторов полей и выполнить цикл по нему, чтобы извлечь каждое поле из соответствующего смещения ...

$proc_yyyymm = '201912'
$delimiter = ','

$stream_in = New-Object -TypeName 'System.IO.StringReader' -ArgumentList $text
$stream_out = New-Object -TypeName 'System.IO.StringWriter'

$lineLength = 82 # Assumes the last line ends with an \r\n and not EOF
$lineChars = New-Object -TypeName 'Char[]' -ArgumentList $lineLength

# This could also be done with 'Offset,Length,NormalizeIfEmpty' | ConvertFrom-Csv
# The Offset property could be omitted in favor of calculating it in the loop
# based on the Length, however this way A) avoids the extra variable/addition,
# B) allows fields to be ignored if desired, and C) allows fields to be output
# in a different order than the input.
$fieldDescriptors = @(
    @{ Offset =  0; Length = 11; NormalizeIfEmpty = $false },
    @{ Offset = 11; Length = 24; NormalizeIfEmpty = $false },
    @{ Offset = 35; Length = 19; NormalizeIfEmpty = $true  },
    @{ Offset = 54; Length = 17; NormalizeIfEmpty = $false },
    @{ Offset = 71; Length =  9; NormalizeIfEmpty = $false }
) | ForEach-Object -Process { [PSCustomObject] $_ }

while (($lastReadCount = $stream_in.ReadBlock($lineChars, 0, $lineLength)) -gt 0)
{
    $stream_out.Write($proc_yyyymm)

    foreach ($fieldDescriptor in $fieldDescriptors)
    {
        # If the first character of a field is whitespace, assume the
        # entire field is as well to avoid a String allocation and Trim()
        # If space is the only possible whitespace character,
        # $lineChars[$fieldDescriptor.Offset] -eq [Char] ' ' may be faster than IsWhiteSpace()
        $fieldText = if ($fieldDescriptor.NormalizeIfEmpty `
            -and [Char]::IsWhiteSpace($lineChars[$fieldDescriptor.Offset])
        ) {
            '0'
        } else {
            # Create a String from the span of Chars at known boundaries and trim it
            (
                New-Object -TypeName 'String' -ArgumentList (
                    $lineChars, $fieldDescriptor.Offset, $fieldDescriptor.Length
                )
            ).Trim()
        }

        $stream_out.Write($delimiter)
        $stream_out.Write($fieldText)
    }

    $stream_out.WriteLine()
}

$stream_out.ToString()

Я , предполагая что прямое извлечение строк будет быстрее, чем регулярное выражение, но я не знаю, что вообще должно быть $true, не говоря уже о PowerShell;только тестирование покажет, что.

Все вышеперечисленные решения дают следующий результат ...

201912,ID,FIRST_NAME,LAST_NAME,COLUMN_NM_TOO_LON,5THCOLUMN
201912,10000000001,MINNIE,MOUSE,COLUMN VALUE LONG,STARTS
201912,10000000002,PLUTO,0,COLUMN VALUE LONG,STARTS
0 голосов
/ 12 декабря 2019

У меня есть решение, которое вы можете проверить if(! $value) { $value = 0 }. На этой странице есть и другие решения. https://stackoverflow.com/a/57647495/6654942

0 голосов
/ 12 декабря 2019

Я попробовал это, но я думаю, что это будет слишком медленно ... все еще тестирую.

PROGRAM

$proc_yyyymm = '201912'
[regex]match_regex = '^(.{10})(.{10})(.{30})(.{30})(.{30})(.{4})(.{8})(.{12})'

# deal with header row
if ($has_header_row) {
   $line = $stream_in.ReadLine()
}

while ($line = $stream_in.ReadLine()) {

   if ($line -match $match_data_regex) {

      $Matched = $Matches[1..($Matches.Count-1)].Trim()

      Foreach ($fld in ($file_info.numeric_fields)) {

         if ($Matched[$fld] -eq '') {
            $Matched[$fld] = 0
         }
      }

      $new_line = ("$proc_yyyymm", "$Matched") -join "`t"
      $stream_out.WriteLine($new_line)
   }
}
...