3. SAX: The Simple API for XML

SAX 는 xml-dev 메일링 리스트를 통한 많은 사람들의 조언을 통해, David Megginson 이 개발하였다. SAX 는 XML 의 파싱에 대해 이벤트-구동(event-driven) 인터페이스를 갖는다. SAX 를 사용하기 위해서, 적절한 인터페이스가 선언된 파이썬 클래스 인스턴스를 하나 생성하고, 이 오브젝트의 적절한 메쏘드를 호출함으로서 파싱을 하게 된다.

이전 버전의 문서에서는 SAX1 을 설명하였지만, 이번 하우투에서는 SAX 버전 2 (SAX2) 를 가지고 설명을 한다.

SAX 는 처음부터 끝까지 구성된 XML 문서를 읽어들여, 다른 문서로 변환하기위해 연산하거나, 그 문서의 정보들을 정리하는 (예를들어 특정 요소의 평균값등을 계산하는 작업) 등에 적합하다. 그러나, 간단한 요소의 내용이나 속성을 다른값으로 바꾸는 등의 간단한 목적에 사용될수는 있어도, 중첩된 어떠한 요소들을 교환한다던지 하는 복잡한 연산등의 문서구조 변환에는 적합하지 않다. 예를 들어, SAX 를 사용하여 속성값이 'greek'인 어떤 요소의 내용을 Greek 문자로 변환하는 등의 작업에는 적합하지만, 전체 책의 챕터를 재배치한다는 등의 작업은 적합하지 않다.

SAX 의 장점은 속도와 단순함에 있다. 만화책들을 나열하기 위한 DTD 가 정의되어 있다고 가정하고, 당신이 당신이 소장하고 있는 만화책중에서 저자(writer)가 Neil Gaiman 인 모든것을 찾으려 한다고 하자. 이러한 특정한 일에 대해서, 검색에 관련이 없는 미술가(artist)나 편집자(editor), 채색가(colourist) 등의 요소에 대한 설명을 확장할 필요는 없다. 그러므로 이럴때에는 요소중 저자(writer)를 제외한 나머지 요소는 무시하도록 클래스 인스턴스를 작성하면 된다.

또다른 이점은, 특정시점에 있어 모든 문서의 내용을 메모리에 올리지 않아도 된다는 것이다. 이것은 매우 큰 문서를 처리할때 매우 유용할것이다.

SAX 는 4개의 기본적인 인터페이스를 가지고 있다. SAX 를 따르는 파서는 데이터를 처리하기 위해 다양한 메쏘드를 호출하며, 이러한 인터페이스를 지원하는 어떠한 오브젝트도 넘겨받을 수 있다. 그러므로, 당신의 작업에 당신의 어플리케이션과 관련된 인터페이스를 이용하면 될것이다.

SAX 인터페이스는 다음과 같다.:

Interface Purpose
ContentHandler 이 인터페이스는 SAX 의 핵심으로, 일반적인 문서 이벤트를 위한 호출이다. 이 메쏘드는 문서의 시작, 요소의 시작과 끝, 요소가 포함하는 내용의 문자를 만날때 호출을 한다.
DTDHandler 기본적인 파싱에 있어 요구되는 DTD 이벤트를 핸들링하기 위해 호출된다. 즉 표기법(XML spec section 4.7)과 파싱되지 않는 엔티티(entity) 선언(XML spec section 4)을만난때 호출을 한다.
EntityResolver 외부 엔티티를 참조하기 위하여 사용된다.만일 문서에 외부 엔티티 참조가 없다면, 이 인터페이스를 실행할 필요가 없을것이다.
ErrorHandler 에러를 처리하기 위해 호출한다.파서는 모든 경고와 에러를 보고하기 위해 이 인터페이스를 통해 메쏘드를 호출한다.

파이썬은 인터페이스에 대한 내용을 지원하지 않으므로 위의 인터페이스는 파이썬 클래스로 재정의 되어야 한다. 기본적으로 메쏘드는 아무런 일도 하지 않는다.(메쏘드의 내용은 파이썬의 pass 명령으로 되어있다.) 그러므로 어플리케이션에서 사용하지 않는 메쏘드에 대해서는 단순히 무시해 버리면 된다.

여기 SAX 를 사용하는 슈도코드의 예가 있다 :

   
   # 핸들러 클래스의 정의
   from xml.sax import Contenthandler, ...
   class docHandler(ContentHandler):
       ...

   # 핸들러 클래스의 인스턴스 생성
   dh = docHandler()

   # XML parser 생성
   parser = ...

   # 파서에 핸들러 인스턴스를 지정
   parser.setContentHandler(dh)

   # 파일 파싱; 핸들러 메소드가 호출될것이다.
   parser.parse(sys.stdin)

3.1. Starting Out

만화책들에 대한 정보를 저장하는 간단한 XML 포맷에 대해 생각해보자. 한권에 대한 간단한 문서가 여기 있다. :

   
   <collection>
     <comic title="Sandman" number='62'>
       <writer>Neil Gaiman</writer>
       <penciller pages='1-9,18-24'>Glyn Dillon</penciller>
       <penciller pages="10-17">Charles Vess</penciller>
     </comic>
   </collection>

XML 문서는 반드시 하나의 최상위 요소를 가지고 있어야한다. 여기서는 "collection" 이 최상위 요소이다. 이것은 하나의 자식 요소인 comic 을 갖는다. comic 요소의 속성으로 책의 제목(title)과 번호(number)가 주어졌으며, 저자(writer)와 미술가(artist)에 대해 하나 또는 그이상의 자식을 가지고 있다. 하나의 간행물(issue)에 대해 여러명의 미술가(artist) 나 저자(writer)가 있을수 있다.

간단한 상태로 시작을 해보자 : FindIssue 라 이름 지어진 문서핸들러(document handler)는 주어진 간행물이 컬랙션 내에 있는지 찾는일을 할것이다.

   
   from xml.sax import saxutils

   class FindIssue(saxutils.DefaultHandler):
       def __init__(self, title, number):
           self.search_title, self.search_number = title, number

DefaultHandler 클래스는 네개의 인터페이스 - ContentHandler, DTDHandler, EntityResolver, 그리고 ErrorHandler 에서 상속을 받는다. 이것은 모든것을 하나의 클래스 내에서 사용하길 원할때 사용하는 방법이다. 만일 각각의 목적에 따라 클래스를 나누길 원하거나, 하나의 인터페이스만을 선언하길 원한다면, 각 인터페이스에 따라 각각 서브클래스를 만들수 있다. 이중에 어느 방법이 더 좋다고 할수는 없으며, 무엇을 할것인가에 따라 적절한 방법을 택하면 될것이다.

클래스가 검색을 하기 위해서, 인스턴스는 무엇을 검색할것인지를 알아야한다. 찾기를 원하는 title 과 발행물 번호는 FindIssue 생성자에게 건내지고, 인스턴스의 일부로 저장될것이다.

자 이제 실제로 동작을 하는 함수를 살펴보도록 하자. 이 간단한 작업은 단지 주어진 요소의 속성을 살펴보는 것이므로 startElement 메소드가 적합할 것이다.

   
    def startElement(self, name, attrs):
        # comic 요소가 아니면 무시하라.
        if name != 'comic': return

        # title 과 number 속성에 대해 검색을 한다.
        title = attrs.get('title', None)
        number = attrs.get('number', None)
        if title == self.search_title and number == self.search_number:
            print title, '#'+str(number), 'found'
startElement() 메쏘드에는 주어진 요소의 이름과 그 요소의 속성을 갖는 인스턴스가 전해진다. 마지막 AttributeList 인터페이스는 파이썬 사전형이다. 그러므로 함수는 먼저 comic 요소인지 확인하고, 특정 title 과 number 속성을 검색하게 된다. 비교에 성공하면 메세지가 출력된다.

startElement() 는 문서의 모든 요소를 만날때마다 호출된다. startElement() 함수의 처음에 'Starting element:' 를 출력하도록 코드를 집어넣는다면, 다음과 같은 출력을 보게 될것이다.

   
   Starting element: collection
   Starting element: comic
   Starting element: writer
   Starting element: penciller
   Starting element: penciller

실제 클래스를 사용하기 위해서, parser 와 FindIssue 인스턴스를 생성하고 이들을 연결하고, 입력값을 파싱하도록 파서를 호출하는 최상위 코드가 필요하다.

   
   from xml.sax import make_parser
   from xml.sax.handler import feature_namespaces

   if __name__ == '__main__':
       # 파서의 생성
       parser = make_parser()
       # 파서에게 XML 이름영역은 무시하도록 전한다.
       parser.setFeature(feature_namespaces, 0)

       # handler 의 생성
       dh = FindIssue('Sandman', '62')

       # 파서에 우리가 만든 핸들러를 사용하도록 전한다.
       parser.setContentHandler(dh)

       # 입력을 파싱한다.
       parser.parse(file)

make_parser 클래스는 파서를 만드는 작업을 자동으로 해준다. 파이썬에는 이미 여러가지 XML 파서가 존재하며, 앞으로도 많은 파서가 추가될 예정이다. 파이썬 1.5 버전에 있는 xmllib.py 는 특별히 빠르지는 않지만, 현재도 사용가능하다. xmllib.py 의 속도를 개선한 버전이 xml.parsers 에 포함되어있다. xml.parsers.expat 모듈은 지금까지는 가장 빠르며, 가능하다면 이것이 선택된다. make_parser 는 어떤 파서가 사용가능한지를 결정하고, 가장 빠른 파서를 선택한다. 그러므로, 각 파서들이 어떻게 다른지에 대해서는 사용자가 알고 있어야 할 필요가 없다. (make_parser 에 당신이 사용하기를 원하는 파서 리스트를 알려줄수도 있다.)

SAX2 에서부터, XML 이름영역(namespace)이 지원된다. 만일 이름영역 프로세싱이 엑티브 되어있다면, startElement 대신 startElementNS 이 호출된다. content 핸들러가 이름영역에 대한 메소드를 정의하지 않았다면, 이름영역 처리를 하지 않겠다고 요구하는 것이다. 이 설정의 기본은 파서에서 파서로 수정을 가한다. 그러므로 안전한 값으로 설정을 하는것이 좋다.

파서 인스턴스를 생성한 후, setContentHandler 를 호출하여 파서에게 사용할 핸들러를 넘겨준다.

예제 XML 문서를 위의 코드에 대입하면, 출력으로 'Sandman #62 found' 가 나올것이다.

3.2. Error Handling

이제 위의 코드에 아래의 파일을 입력으로 넣어보자:

   
   <collection>
     &foo;
     <comic title="Sandman" number='62'>
   </collection>

&foo; 엔티티는 알려지지 않은것이며, comic 요소는 닫히지 않았다. (만일 빈 내용을 갖는다 하더라도 ">" 로 닫기 전에 "/" 를 사용하여야 한다.) 이러한 결과로, SAXParseException 이 발생한다.

   
   xml.sax._exceptions.SAXParseException: undefined entity at None:2:2

ErrorHandler 인터페이스의 기본코드는 어떠한 에러에 대해서도 자동적으로 예외를 발생시킨다; 만일 이러한 에러처리를 원한다면, 에러 핸들러를 수정할 필요가 없을것이다. 또한, 자기 자신만의 ErrorHandler 인터페이스나, error(), fatalError() 에 대한 최소한의 오버라이드를 만들수도 있다. 각 메소드는 최소로 한 줄 일수도 있다. warning, error, fatalError 에 대한 ErrorHandler 인터페이스 메쏘드는 모두 하나의 인자값 - exception 인스턴스를 갖는다. exception 은 항상 SAXException 의 서브클래스이며, str() 를 이용하여 읽을수있는 문제점을 설명하는 에러 메세지로 만들수있다.

다음은, 여러가지 에러발생 상황을 재설정하기 위해, 세가지중 하나의 메소드를 예외를 출력하도록 정의하였다:

   
    def error(self, exception):
        import sys
        sys.stderr.write("\%s\n" \% exception)

이 정의에 의하면, 치명적이지 않은 error 는 에러 메세지를 출력하게 되지만, 치명적인 에러(fatal error) 는 트레이스백(traceback)을 생성하는 일을 계속할 것이다.

3.3. Searching Element Content

특정 저자(author) 가 쓴 모든 발행물(issue) 를 출력하는, 조금더 복잡한 작업을 해보자. Let's tackle a slightly more complicated task, printing out all issues written by a certain author. 이것은 저자의 이름이 <writer>Peter Milligan</writer> 과 같이 writer 요소의 내부에 존재하므로, 요소의 내용을 검색해야한다.

이러한 검색은 다음과 같은 알고리즘을 따라 처리된다:

  1. startElement 메쏘드는 조금더 복잡하게 된다. comic 요소에 대해, 검색기준에 적합한 comic 을 나중에 찾기위해, 핸들러는 title 과 number 를 저장하여야한다. writer 요소에 대해서는, inWriterContent 플래그를 참으로 설정하고, writerName 속성을 빈 문자열로 설정한다.

  2. XML 태그밖의 문자들(Characters)을 처리하여야 한다. inWriterContent 가 참이면, 이 문자들을 writeName 문자열에 추가한다.

  3. writer 요소가 끝나면, writerName 속성안의 모든 요소의 내용을 수집한것이 된다. 그러므로, 찾고자하는 name 과 동일한것인지 검사하고, 만일 맞는다면, 이 comic 에 대한 정보를 출력한다. 다시 inWriterContent 를 거짓으로 설정한다.

여기에 첫번째 부분의 코드가 있다.

   
   from xml.sax import ContentHandler
   import string

   def normalize_whitespace(text):
       "문자열에서 중복적으로 나타나는 공백문자(whitespace)를 제거한다."
       return string.join(string.split(text), ' ')

   class FindWriter(ContentHandler):
       def __init__(self, search_name):
           # 찾고자 하는 이름을 저장한다.
           self.search_name = normalize_whitespace(search_name)

           # 플래그를 거짓(false)으로 초기화한다.
           self.inWriterContent = 0

       def startElement(self, name, attrs):
           # comic 요소이면, title 과 issue 를 저장한다.
           if name == 'comic':
               title = normalize_whitespace(attrs.get('title', ""))
               number = normalize_whitespace(attrs.get('number', ""))
               self.this_title = title
               self.this_number = number

           # writer 요소의 시작이면, 플래그를 설정한다.
           elif name == 'writer':
               self.inWriterContent = 1
               self.writerName = ""

startElement() 메소드에 대해서는 이전에 설명하였다. 이제 요소의 내용을 어떻게 처리하는지 보자.

normalize_whitespace() 함수는 매우 중요하므로, 코드내에 이것을 사용하여야 한다. XML 은 공백문자(whitespace) 에 매우 민감하다; 당신은 당신이 원하는 곳에 여분의 공백과 개행문자들을 포함시킬 수 있다. 이것은 속성값이나 요소의 내용을 비교하기 전에, 공백문자를 정상적으로 만들어야 함을 의미한다; 만일 두개의 요소의 내용이 다른 수의 공백문자를 갖는다면, 비교연산은 잘못된 결과를 만들어낼 것이다.

   
    def characters(self, ch):
        if self.inWriterContent:
            self.writerName = self.writerName + ch

characters() 메쏘드는 XML 태그밖의 문자들을 만날때 호출된다. ch 는 문자들의 스트링이지만, 반드시 바이트 스트링인것은 아니다; 파서는 문서의 일부분인 버퍼 오브젝트를 제공하거나, 유니코드 오브젝트를 건내주게 된다(파이썬 2.0 의 expat 파서). parsers may also provide a buffer object that is a slice of the full document, or they may pass Unicode objects (as the expat parser does in Python 2.0).

모든 문자들이 한번의 함수 호출로 처리된다고 가정하지 말기 바란다. 위의 예를 들어보면, "Peter Milligan" 라는 문자열에 대해 한번의 characters() 호출이 있거나, 또는 각 문자에 대해 한번씩 characters() 의 호출이 있을 수 있다. 더욱 현실적으로, 만일 내용이 "Wagner &amp; Seagle" 과 같이 엔티티 참조를 가지고 있다면, 파서는 "Wagner ", 엔티티 참조를 상징하는 "&", 그리고 " Seagle" 에 대해 각 세번의 메소드 호출을 하게 된다.

FindWriter 의 두번째 과정에서, characters() 는 단지 inWriterContent 를 검사하고, 이 값이 참이면, 문자를 문자열에 추가하는 작업을 한다.

마지막으로, writer 요소가 끝날때, 전체 이름이 수집될것이고, 이것을 우리가 찾고자하는 이름과 비교하게 된다.

   
    def endElement(self, name):
        if name == 'writer':
            self.inWriterContent = 0
            self.writerName = normalize_whitespace(self.writerName)
            if self.search_name == self.writerName:
                print 'Found:', self.this_title, self.this_number

다른 공백문자수에 의한 혼동을 막기위해, normalize_whitespace() 함수가 호출되었다. 이것은 이 DTD 내에서는, 이 요소가 가지고 있는 공백문자가 중요하지 않기 때문이다.

마침 태그(End tags)는 속성을 가질수 없으므로, 여기에는 attrs 인자가 없다. "<arc name="Season of Mists"/>" 와 같이 속성을 갖는 빈 태그는, startElement() 를 호출한 후 바로 endElement() 를 호출한다.

XXX 외부 엔티티 처리는 어떻게 할것인가? 그들에 대한 처리가 특별히 필요한가?

3.4. 관련 홈페이지

http://www.megginson.com/SAX/

SAX 홈페이지다. 여기에는 가장 최신의 명세서와 다양한 언어와 플랫폼상의 SAX 도구 리스트가 있다. 현재는 자바 중심적으로 되어있다.