Обработка структурированного текста с помощью регулярных выражений. Домашняя страничка Андрея Скляревского.

Обработка структурированного текста с помощью регулярных выражений

Большая часть современного программного обеспечения обрабатывает текст. Если нужно убедиться, что пользователь ввёл число, а не его словесный эквивалент в поле «Высота потолка», то так или иначе придётся проверять строковое значение перед его использованием. Так же часто требуется превращение структурированного текста в список объектов, например чтобы сохранить их в базе данных.

Большинство задач, связанных с обработкой текста, лучше всего решается при помощи регулярных выражений (regular expressions). Данная статья познакомит читателя с тем, как использовать регулярные выражения в .NET (код примеров на C#).

Допустим, необходимо обработать текст определённого формата, содержащий список работников предприятия. Создадим проект типа Console Application StructuredTextParser (имя не имеет значения) в Visual Studio 2010. Добавим туда код генерации исходного текста:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
 
namespace StructuredTextParser {
	class Program {
		private static readonly string[] LoremIpsum =
			"Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum".
			Split(' ').Select(w => Char.ToUpper(w[0]) + w.Substring(1).Trim(',')).ToArray();
 
		private static readonly Random Random = new Random();
 
		private static string NextWord() {
			return LoremIpsum[Random.Next(0, LoremIpsum.Length - 1)];
		}
 
		private static string GenerateRandomStructuredText() {
			StringBuilder textBuilder = new StringBuilder();
			for (int i = 0; i < 10; i++) {
				textBuilder.AppendFormat("First name:\t{0}\r\n" +
										 "Last name:\t{1}\r\n" +
										 "Date of birth:\t{2}\r\n" +
										 "Id:\t{3}\r\n\r\n",
										 NextWord(), NextWord(), new DateTime(Random.Next(1951, 1990), Random.Next(1, 12), Random.Next(1, 28)),
										 Random.Next(100, 100 + (1 + i) * 120));
			}
			return textBuilder.ToString();
		}
 
		static void Main(string[] args) {
			Console.WriteLine("Text:\r\n\r\n{0}", GenerateRandomStructuredText());
		}
	}
}

Метод GenerateRandomStructuredText случайным образом генерирует форматированный текст, используя тестовый пассаж Lorem ipsum:

First name:     Incididunt
Last name:      Aliquip
Date of birth:  06/23/1975 00:00:00
Id:     118
 
First name:     Duis
Last name:      Magna
Date of birth:  03/11/1959 00:00:00
Id:     271
 
First name:     Duis
Last name:      In
Date of birth:  11/02/1967 00:00:00
Id:     408
 
First name:     Pariatur.
Last name:      Cillum
Date of birth:  06/12/1984 00:00:00
Id:     548
 
First name:     Duis
Last name:      Aliquip
Date of birth:  07/27/1954 00:00:00
Id:     166
 
First name:     Nulla
Last name:      Sed
Date of birth:  08/12/1952 00:00:00
Id:     224
 
First name:     Aliqua.
Last name:      Commodo
Date of birth:  10/01/1978 00:00:00
Id:     167
 
First name:     Id
Last name:      Est
Date of birth:  08/05/1953 00:00:00
Id:     180
 
First name:     Non
Last name:      Consectetur
Date of birth:  09/25/1958 00:00:00
Id:     1093
 
First name:     Non
Last name:      Dolore
Date of birth:  08/26/1952 00:00:00
Id:     540
Во многих проектах рано или поздно возникает необходимость импортировать данные подобного рода. Пример, конечно, в достаточной мере упрощён, т.к. имеет строгий формат и не очень много полей в каждой записи. В реальных случаях часть данных может иногда отсутствовать, другая часть может иметь формат, зависящий от других полей, и, что вызывает больше всего трудностей, может встречаться вложенность сущностей.

Возвращаясь к задаче, заметим, что каждая запись имеет 4 поля — имя, фамилию, дату рождения, и идентификационный номер сотрудника. Словами формат можно описать примерно следующим образом: фраза First name:, имя, фраза Last name:, фамилия, фраза Date of birth:, дата рождения, фраза Id:, идентификационный номер человека. Обработку такого текста можно реализовать при помощи функций String.IndexOf и String.Substring, однако в большинстве случаев куда быстрее написать регулярное выражение. Попробуем преобразовать словесное описание формата записи сотрудника в регулярное выражение.

Искомое Регулярное выражение
First name: First name:
Имя (все символы до следующей строки) (?<firstName>[^\r\n]*)
Last name: Last name:
Фамилия (?<lastName>[^\r\n]*)
Date of birth: Date of birth:
Дата рождения (?<dateOfBirth>[^\r\n]*)
Id: Id:
Идентификационный номер (только цифры) (?<id>\d+)

Как видно из таблицы, константные строковые выражения можно оставить без изменений, специально описывается только вид искомых значений. Полное описание синтаксиса регулярных выражений выходит за рамки данной статьи. Однако, рассмотрим часть синтаксиса, использованного в нужном для данного случая выражении:

Выражение Описание
(...) Безымянная группа — значение внутри скобок может быть позднее использовано отдельно от полного совпадения (части текста, подходящего под формат, описанный регулярным выражением)
(?<groupName>...) Именованная группа — значение между символами > и ) может быть получено по имени groupName
[abcdefg] Текущий символ должен быть одним из abcdefg
[^abcdefg] Текущий символ может быть любым, кроме одного из abcdefg
\r\n Следующие два символа должны означать новую строку (Windows)
\s Текущий символ должен быть одним из пробелов ('\t', ' ', и т.д.)
\d Текущий символ должен быть цифрой
* Предыдущее определение может как не встречаться совсем, так и повторяться бесконечно
+ Предыдущее определение должно встречаться как минимум один раз, и может повторяться бесконечно

Объединив части выражения из первой таблицы через символы новой строки \r\n, а также улучшив его, заменив пробелы на \s, получим итоговое выражение:

First\sname:\s?(?<firstName>[^\r\n]*)\r\nLast\sname:\s?(?<lastName>[^\r\n]*)\r\nDate\sof\sbirth:\s?(?<dateOfBirth>[^\r\n]*)\r\nId:\s?(?<id>\d+)

Добавим в класс Program статический экземпляр класса Regex с нужным выражением:

private static readonly Regex RegexPerson = new Regex(
			"First\\sname:\\s?(?<firstName>[^\\r\\n]*)\r\nLast\\sname:\\s?(?<lastName>[^\r\n]*)\r\n" +
			"Date\\sof\\sbirth:\\s?(?<dateOfBirth>[^\\r\\n]*)\r\nId:\\s?(?<id>\\d+)", RegexOptions.Compiled | RegexOptions.Singleline);
Опция RegexOptions.Compiled означает, что данное выражение можно скомпилировать для улучшения производительности при многократных вызовах. RegexOptions.Singleline сообщает обработчику, что подаваемый на вход текст должен обрабатываться как единое целое, а не как набор отдельных строк.

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

public class Person {
	public string FirstName { get; set; }
	public string LastName { get; set; }
	public DateTime DateOfBirth { get; set; }
	public int Id { get; set; }
}
И сам обработчик:

public static IEnumerable<Person> ParseText(string text) {
	for (Match match = RegexPerson.Match(text); match.Success; match = match.NextMatch()) {
		string firstName = match.Groups["firstName"].Value;
		string lastName = match.Groups["lastName"].Value;
		string dateOfBirthString = match.Groups["dateOfBirth"].Value;
		string idString = match.Groups["id"].Value;
		DateTime dateOfBirth;
		int id;
		if (DateTime.TryParse(dateOfBirthString, out dateOfBirth) && Int32.TryParse(idString, out id)) {
			yield return new Person {
				FirstName = firstName,
				LastName = lastName,
				DateOfBirth = dateOfBirth,
				Id = id
			};
		}
	}
}
Весьма рекомендуется использовать перечисляемое объектов (IEnumerable<T>) вместо их списка (List<T>), т.к. это обычно позволяет эффективнее использовать ресурсы. В тех случаях, когда текстовые данные должны быть преобразованы в нестроковый тип (например, в тип даты или числа), необходимо проверять, что все преобразования удались, т.к. иначе цикл может прерваться посередине из-за некорректных данных одной из записей. Часто эту проблему можно решить усложнением регулярного выражения: например, для поля Id в примере выше используется выражение, разрешающее только цифры. Даже для дат можно написать выражение, поддерживающее високосность годов и 29-ое февраля.

Теперь заменим код метода Main, чтобы вывести результаты обработки текста:

static void Main(string[] args) {
	string text = GenerateRandomStructuredText();
	Console.WriteLine("Original text:\r\n\r\n{0}\r\n\r\nParsed values:\r\n\r\n", text);
	foreach (var person in ParseText(text)) {
		Console.WriteLine("Employee #{0}: {1} {2}, born on {3}", person.Id, person.FirstName, person.LastName,
			person.DateOfBirth.ToLongDateString());
	}
}
И убедимся, что сгенерированный текст действительно успешно преобразовался в объекты:

Employee #118: Incididunt Aliquip, born on Monday, 23 June 1975
Employee #271: Duis Magna, born on Wednesday, 11 March 1959
Employee #408: Duis In, born on Thursday, 02 November 1967
Employee #548: Pariatur. Cillum, born on Tuesday, 12 June 1984
Employee #166: Duis Aliquip, born on Tuesday, 27 July 1954
Employee #224: Nulla Sed, born on Tuesday, 12 August 1952
Employee #167: Aliqua. Commodo, born on Sunday, 01 October 1978
Employee #180: Id Est, born on Wednesday, 05 August 1953
Employee #1093: Non Consectetur, born on Thursday, 25 September 1958
Employee #540: Non Dolore, born on Tuesday, 26 August 1952
Среди задач, с лёгкостью решающихся при помощи регулярных выражений, можно отметить следующие: проверка данных на корректность, фильтрация HTML или текста, обработка огромных массивов текстов. Регулярные выражения с некоторыми ограничениями поддерживаются и в JavaScript, что позволяет проверять данные на клиенте, или даже некоторым образом их обрабатывать перед отправкой на сервер. 

Статья была впервые опубликована 15-го августа 2010 года в блоге.