2024年9月

lxml官方入门教程(The lxml.etree Tutorial)翻译

说明:

  • 首次发表日期:2024-09-05
  • 官方教程链接:
    https://lxml.de/tutorial.html
  • 使用KIMI和豆包机翻
  • 水平有限,如有错误请不吝指出

这是一个关于使用lxml.etree处理XML的教程。它简要概述了ElementTree API的主要概念,以及一些简单的增强功能,这些功能可以让您作为程序员的生活更轻松。

有关API的完整参考,请查看
生成的API文档

导入lxml.etree的常见方式如下:

from lxml import etree

如果你的代码仅使用ElementTree API,并且不依赖于
lxml.etree
任何的特有功能,您还可以使用以下导入链来回退到Python标准库中的ElementTree:

try:
    from lxml import etree
    print("running with lxml.etree")
except ImportError:
    import xml.etree.ElementTree as etree
    print("running with Python's xml.etree.ElementTree")

为了帮助编写可移植代码,本教程在示例中明确指出了所呈现API的哪一部分是lxml.etree对原始ElementTree API的扩展。

The Element class

元素(Element)是ElementTree API的主要容器对象。大部分XML树功能都是通过这个类访问的。元素(Elements)可以通过
Element
factory轻松创建:

root = etree.Element("root")

元素的XML标签名称可以通过
tag
属性访问:

print(root.tag)

元素在XML树结构中组织。要创建子元素并将它们添加到父元素,您可以使用
append()
方法:

root.append(etree.Element("child"))

然而,这种情况非常常见,因此有一个更简短且效率更高的方法来实现这一点:
SubElement
工厂。它接受与
Element
工厂相同的参数,但另外需要将父元素作为第一个参数:

child2 = etree.SubElement(root, "child2")
child3 = etree.SubElement(root, "child3")

要确认这确实是XML,您可以序列化您创建的树:

etree.tostring(root)
b'<root><child1/><child2/><child3/></root>'

我们将创建一个小型辅助函数,为我们美观地打印XML:

def prettyprint(element, **kwargs):
    xml = etree.tostring(element, pretty_print=True, **kwargs)
    print(xml.decode(), end='')
prettyprint(root)
<root>
  <child1/>
  <child2/>
  <child3/>
</root>

Elements are lists

为了便于直接访问这些子元素,元素尽可能地模仿了普通Python列表的行为:

>>> child = root[0]
>>> print(child.tag)
child1

>>> print(len(root))
3

>>> root.index(root[1])  # lxml.etree only!
1

>>> children = list(root)

>>> for child in root:
...     print(child.tag)
child1
child2
child3

>>> root.insert(0, etree.Element("child0"))
>>> start = root[:1]
>>> end   = root[-1:]

>>> print(start[0].tag)
child0
>>> print(end[0].tag)
child3

在ElementTree 1.3和lxml 2.0之前,您还可以检查元素的真值,以查看它是否有子元素,即查看子元素列表是否为空:

if root:   # this no longer works!
    print("The root element has children")

这种做法不再被支持,因为人们倾向于期望“某物”(something)evaluates为
True
,并期望元素(Elements)是“某物”,无论它们是否有子元素。因此,许多用户发现,任何元素在像上面的if语句中评估为False是令人惊讶的。相反,使用
len(element)
,这既更明确,也更少出错。

print(etree.iselement(root)) # test if it's some kind of Element
True
if len(root):  # test if it has children
    print("The root element has children")

在另一个重要的场景下,lxml中元素(从2.0及以后版本)的行为与列表(lists)的行为以及原始ElementTree(1.3版本之前或Python 2.7/3.2之前)的行为有所不同:

for child in root:
    print(child.tag)
child0
child1
child2
child3
root[0] = root[-1] # this moves the element in lxml.etree!
for child in root:
    print(child.tag)
child3
child1
child2

在这个例子中,最后一个元素被
移动
到了一个不同的位置,而不是被复制,也就是说,当它被放到一个不同的位置时,它会自动从之前的位置被移除。在列表中,对象可以同时出现在多个位置,上述赋值操作只会将项目引用复制到第一个位置,因此两者包含完全相同的项目:

>>> l = [0, 1, 2, 3]
>>> l[0] = l[-1]
>>> l
[3, 1, 2, 3]

请注意,在原始的ElementTree中,单个元素对象可以位于任何数量的树中的任何位置,这允许进行与列表相同的复制操作。明显的不足是,对这样的元素进行的修改将应用于它在树中出现的所有位置,这可能是也可能不是预期的。

备注
:在lxml中,上述赋值操作会移动元素,与lists和原始的ElementTree中不同。

这种差异的好处是,在lxml.etree中的一个元素总是恰好有一个父元素,这可以通过getparent()方法查询。这在原始的ElementTree中是不支持的。

root is root[0].getparent() # lxml.etree only!

如果您想将元素复制到lxml.etree中的不同位置,请考虑使用Python标准库中的copy模块创建一个独立的深拷贝:

from copy import deepcopy

element = etree.Element("neu")
element.append(deepcopy(root[1]))
print(element[0].tag)
# child1
print([c.tag for c in root])
# ['child3', 'child1', 'child2']

元素的兄弟(或邻居)作为下一个和上一个元素进行访问:

root[0] is root[1].preprevious()  # lxml.etree only!
# True
root[1] is root[0].getnext() # lxml.etree only!

Elements carry attributes as a dict

XML元素支持属性(attributes)。您可以直接在Element工厂中创建它们:

root = etree.Element("root", interesting="totally")
etree.tostring(root)
# b'<root interesting="totally"/>'

属性只是无序的
名称-值
对,因此通过元素的类似字典的接口处理它们非常方便:

print(root.get("interesting"))
# totally
print(root.get("hello"))
# None
root.set("hello", "Huhu")
print(root.get("hello"))
# Huhu
etree.tostring(root)
# b'<root interesting="totally" hello="Huhu"/>'
sorted(root.keys())
# ['hello', 'interesting']
for name, value in sorted(root.items()):
    print('%s = %r' % (name, value))
# hello = 'Huhu'
# interesting = 'totally'

在您想要进行项目查找或有其他原因需要获取一个“真实”的类似字典的对象的情况下,例如为了传递它,您可以使用
attrib
属性:

>>> attributes = root.attrib

>>> print(attributes["interesting"])
totally
>>> print(attributes.get("no-such-attribute"))
None

>>> attributes["hello"] = "Guten Tag"
>>> print(attributes["hello"])
Guten Tag
>>> print(root.get("hello"))
Guten Tag

请注意,attrib是一个由元素本身支持(backed)的类似字典的对象。这意味着对元素的任何更改都会反映在attrib中,反之亦然。这也意味着只要XML树有一个元素的attrib在使用中,XML树就会在内存中保持活动状态。要获取一个不依赖于XML树的属性的独立快照,将其复制到一个字典中:

d = dict(root.attrib)
sorted(d.items())
# ('hello', 'Guten Tag'), ('interesting', 'totally')]

Elements contain text

元素可以包含文本:

root = etree.Element("root")
root.text = "TEXT"

print(root.text)
# TEXT

etree.tostring(root)
# b'<root>TEXT</root>'

在许多XML文档(以数据为中心的文档)中,这是唯一可以找到文本的地方。它被树层次结构最底层的一个叶子标签封装。

然而,如果XML用于标记文本文档,如(X)HTML,文本也可以出现在不同元素之间,就在树的中间:

<html><body>Hello<br/>World</body></html>

在这里,
<br/>
标签被文本包围。这通常被称为文档样式或混合内容XML。元素通过它们的tail属性支持这一点。它包含直接跟随元素的文本,直到XML树中的下一个元素:

>>> html = etree.Element("html")
>>> body = etree.SubElement(html, "body")
>>> body.text = "TEXT"

>>> etree.tostring(html)
b'<html><body>TEXT</body></html>'

>>> br = etree.SubElement(body, "br")
>>> etree.tostring(html)
b'<html><body>TEXT<br/></body></html>'

>>> br.tail = "TAIL"
>>> etree.tostring(html)
b'<html><body>TEXT<br/>TAIL</body></html>'

.text

.tail
这两个属性足以表示XML文档中的任何文本内容。这样,ElementTree API 除了 “Element” 类之外不需要任何特殊的文本节点,这些节点往往经常会常造成阻碍(正如你可能从传统 DOM API 中了解到的那样)。

然而,也有一些情况下,尾随(tail)文本也会碍事。例如,当您序列化树中的一个元素时,您并不总是希望其尾随文本出现在结果中(尽管您仍然希望包含其子元素的尾部文本)。为此,
tostring()
函数接受关键字参数
with_tail

>>> etree.tostring(br)
b'<br/>TAIL'
>>> etree.tostring(br, with_tail=False) # lxml.etree only!
b'<br/>'

如果你只想读取文本,即不包含任何中间标签,你必须以正确的顺序递归地连接所有的文本和尾部属性。同样,“
tostring()
” 函数可以提供帮助,这次使用 “
method
” 关键字。

>>> etree.tostring(html, method="text")
b'TEXTTAIL'

Using XPath to find text

提取树文本内容的另一种方式是XPath,它还允许您将单独的文本块提取到一个列表中:

>>> print(html.xpath("string()")) # lxml.etree only!
TEXTTAIL
>>> print(html.xpath("//text()")) # lxml.etree only!
['TEXT', 'TAIL']

如果您想更频繁地使用这个功能,您可以将其封装在一个函数中:

>>> build_text_list = etree.XPath("//text()") # lxml.etree only!
>>> print(build_text_list(html))
['TEXT', 'TAIL']

请注意,XPath返回的字符串结果是一个特殊的“智能”对象,它了解其来源。您可以通过其
getparent()
方法询问它来自哪里,就像您对元素所做的那样:

>>> texts = build_text_list(html)
>>> print(texts[0])
TEXT
>>> parent = texts[0].getparent()
>>> print(parent.tag)
body

>>> print(texts[1])
TAIL
>>> print(texts[1].getparent().tag)
br

您还可以找出它是普通文本内容还是尾随文本:

>>> print(texts[0].is_text)
True
>>> print(texts[1].is_text)
False
>>> print(texts[1].is_tail)
True

虽然这对text()函数的结果有效,但lxml不会告诉您由XPath函数string()或concat()构造的字符串值的来源:

>>> stringify = etree.XPath("string()")
>>> print(stringify(html))
TEXTTAIL
>>> print(stringify(html).getparent())
None

Tree iteration

对于上述这样的问题,当你想要递归地遍历树并对其元素进行一些操作时,树迭代(tree iteration)是一个非常方便的解决方案。元素(Elements)为此提供了一个树迭代器。它按照文档顺序生成元素,即与将树序列化为XML时其标签出现的顺序一致。

>>> root = etree.Element("root")
>>> etree.SubElement(root, "child").text = "Child 1"
>>> etree.SubElement(root, "child").text = "Child 2"
>>> etree.SubElement(root, "another").text = "Child 3"

>>> prettyprint(root)
<root>
  <child>Child 1</child>
  <child>Child 2</child>
  <another>Child 3</another>
</root>

>>> for element in root.iter():
...     print(f"{element.tag} - {element.text}")
root - None
child - Child 1
child - Child 2
another - Child 3

如果您知道您只对单个标签感兴趣,可以将标签名称传递给
iter()
,让它为您过滤。从lxml 3.0开始,您还可以传递多个标签,在迭代期间拦截多个标签。

>>> for element in root.iter("child"):
...     print(f"{element.tag} - {element.text}")
child - Child 1
child - Child 2

>>> for element in root.iter("another", "child"):
...     print(f"{element.tag} - {element.text}")
child - Child 1
child - Child 2
another - Child 3

默认情况下,迭代会生成树中的所有节点,包括ProcessingInstructions、Comments和Entity实例。如果您想确保只返回Element对象,可以将Element工厂作为标签参数传递:

>>> root.append(etree.Entity("#234"))
>>> root.append(etree.Comment("some comment"))

>>> for element in root.iter():
...     if isinstance(element.tag, str):
...         print(f"{element.tag} - {element.text}")
...     else:
...         print(f"SPECIAL: {element} - {element.text}")
root - None
child - Child 1
child - Child 2
another - Child 3
SPECIAL: &#234; - &#234;
SPECIAL: <!--some comment--> - some comment

>>> for element in root.iter(tag=etree.Element):
...     print(f"{element.tag} - {element.text}")
root - None
child - Child 1
child - Child 2
another - Child 3

>>> for element in root.iter(tag=etree.Entity):
...     print(element.text)
&#234;

请注意,传递通配符“
*
”作为标签名也将生成所有的
Element
节点(并且只有元素节点)。

for element in root.iter(tag="*"):
    if isinstance(element.tag, str):
        print(f"element.tag - {element.text}")
    else:
        print(f"SPECIAL: {element} - {element.text}")
element.tag - None
element.tag - Child 1
element.tag - Child 2
element.tag - Child 3


lxml.etree
中,
elements
为树中的所有方向提供了进一步的迭代器:子节点(
iterchildren()
)、父节点(或者更确切地说是祖先节点)(
iterancestors()
)和兄弟节点(
itersiblings()
)。

Serialisation

序列化通常使用
tostring()
函数返回字符串,或者使用
ElementTree.write()
方法写入文件、类文件对象(file-like object)或URL(通过FTP PUT或HTTP POST)。这两个调用都接受相同的关键字参数,如
pretty_print
用于格式化输出,或者
encoding
用于选择除纯ASCII之外的特定输出编码:

>>> root = etree.XML('<root><a><b/></a></root>')

>>> etree.tostring(root)
b'<root><a><b/></a></root>'

>>> xml_string = etree.tostring(root, xml_declaration=True)
>>> print(xml_string.decode(), end='')
<?xml version='1.0' encoding='ASCII'?>
<root><a><b/></a></root>

>>> latin1_bytesstring = etree.tostring(root, encoding='iso8859-1')
>>> print(latin1_bytesstring.decode('iso8859-1'), end='')
<?xml version='1.0' encoding='iso8859-1'?>
<root><a><b/></a></root>

>>> print(etree.tostring(root, pretty_print=True).decode(), end='')
<root>
  <a>
    <b/>
  </a>
</root>

请注意,美观打印(
pretty_print
)会在末尾附加一个新行。因此,我们在这里使用
end=''
选项,以防止
print()
函数添加另一个换行符。

为了在序列化之前对美观打印(
pretty_print
)进行更细粒度的控制,您可以使用
indent()
函数(在lxml 4.5中添加)在树中添加空白缩进:

>>> root = etree.XML('<root><a><b/>\n</a></root>')
>>> print(etree.tostring(root).decode())
<root><a><b/>
</a></root>

>>> etree.indent(root)
>>> print(etree.tostring(root).decode())
<root>
  <a>
    <b/>
  </a>
</root>

>>> root.text
'\n  '
>>> root[0].text
'\n    '

>>> etree.indent(root, space="    ")
>>> print(etree.tostring(root).decode())
<root>
    <a>
        <b/>
    </a>
</root>

>>> etree.indent(root, space="\t")
>>> etree.tostring(root)
b'<root>\n\t<a>\n\t\t<b/>\n\t</a>\n</root>'

在lxml 2.0及更高版本以及xml.etree中,序列化函数不仅可以进行XML序列化。您可以通过传递method关键字来序列化为HTML或提取文本内容:

>>> root = etree.XML(
...    '<html><head/><body><p>Hello<br/>World</p></body></html>')

>>> etree.tostring(root)  # default: method = 'xml'
b'<html><head/><body><p>Hello<br/>World</p></body></html>'

>>> etree.tostring(root, method='xml')  # same as above
b'<html><head/><body><p>Hello<br/>World</p></body></html>'

>>> etree.tostring(root, method='html')
b'<html><head></head><body><p>Hello<br>World</p></body></html>'

>>> prettyprint(root, method='html')
<html>
<head></head>
<body><p>Hello<br>World</p></body>
</html>

>>> etree.tostring(root, method='text')
b'HelloWorld'

与XML序列化一样,纯文本序列化的默认编码是ASCII:

>>> br = next(root.iter('br'))  # get first result of iteration
>>> br.tail = 'Wörld'

>>> etree.tostring(root, method='text')  # doctest: +ELLIPSIS
Traceback (most recent call last):
  ...
UnicodeEncodeError: 'ascii' codec can't encode character '\xf6' ...

>>> etree.tostring(root, method='text', encoding="UTF-8")
b'HelloW\xc3\xb6rld'

在这里,将序列化目标设为Python文本字符串(text string)而不是字节字符串(byte string)可能会很方便。只需将'unicode'作为编码传递:

>>> etree.tostring(root, encoding='unicode', method='text')
'HelloWörld'
>>> etree.tostring(root, encoding='unicode')
'<html><head/><body><p>Hello<br/>Wörld</p></body></html>'

W3C有一篇关于Unicode字符集和字符编码的好文章:
https://www.w3.org/International/tutorials/tutorial-char-enc/

The ElementTree class

ElementTree
主要是一个围绕具有根节点的树的文档包装器。它提供了一些用于序列化和一般文档处理的方法。

root = etree.XML('''<?xml version="1.0"?>
<!DOCTYPE root SYSTEM "test" [ <!ENTITY tasty "parsnips"> ]>
<root>
    <a>&tasty;</a>
</root>
''')
tree = etree.ElementTree(root)
print(tree.docinfo.xml_version)
1.0
print(tree.docinfo.doctype)
<!DOCTYPE root SYSTEM "test">
tree.docinfo.public_id = '-//W3C//DTD XHTML 1.0 Transitional//EN'
tree.docinfo.system_url = 'file://local.dtd'
print(tree.docinfo.doctype)
<!DOCTYPE root PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "file://local.dtd">

当您调用
parse()
函数解析文件或类文件对象(file-like object)时(见下文的解析部分),您得到的也是一个ElementTree。

一个重要的不同之处在于,
ElementTree
类序列化为一个完整的文档,而不是单个
Element
。这包括顶级(top-level)处理指令和注释,以及文档中的DOCTYPE和其他DTD内容:

>>> prettyprint(tree)  # lxml 1.3.4 and later
<!DOCTYPE root PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "file://local.dtd" [
<!ENTITY tasty "parsnips">
]>
<root>
  <a>parsnips</a>
</root>

在原始的xml.etree.ElementTree实现以及直到1.3.3版本的lxml中,输出看起来与仅序列化根元素时相同:

>>> prettyprint(tree.getroot())
<root>
  <a>parsnips</a>
</root>

在lxml 1.3.4中,这种序列化行为发生了变化。以前,树在没有DTD内容的情况下被序列化,这使得lxml在输入输出循环中丢失了DTD信息。

Parsing from strings and files

lxml.etree支持以多种方式解析XML,并且可以从所有重要的源解析,即字符串、文件、URL(http/ftp)和类文件对象(file-like object)。主要的解析函数是
fromstring()

parse()
,都以源作为第一个参数调用。默认情况下,它们使用标准解析器,但您总是可以作为第二个参数传递不同的解析器。

The fromstring() function

fromstring()
函数是解析字符串的最简单方法:

>>> some_xml_data = "<root>data</root>"

>>> root = etree.fromstring(some_xml_data)
>>> print(root.tag)
root
>>> etree.tostring(root)
b'<root>data</root>'
print(type(root))
# <class 'lxml.etree._Element'>

The XML() function

XML()
函数的行为类似于
fromstring()
函数,但通常用于将XML字面量直接写入源代码:

>>> root = etree.XML("<root>data</root>")
>>> print(root.tag)
root
>>> etree.tostring(root)
b'<root>data</root>'
print(type(root))
# <class 'lxml.etree._Element'>

还有一个相应的函数
HTML()
用于HTML字面量。

>>> root = etree.HTML("<p>data</p>")
>>> etree.tostring(root)
b'<html><body><p>data</p></body></html>'
print(type(root))
# <class 'lxml.etree._Element'>

The parse() function

parse()
函数用于从文件和类文件对象(file-like object)解析。

作为此类类文件对象的一个例子,以下代码使用BytesIO类从字符串而不是外部文件中读取。然而,在现实生活中,您显然会避免这样做,而是使用像上面提到的
fromstring()
这样的字符串解析函数。

>>> from io import BytesIO
>>> some_file_or_file_like_object = BytesIO(b"<root>data</root>")

>>> tree = etree.parse(some_file_or_file_like_object)

>>> etree.tostring(tree)
b'<root>data</root>'

请注意,
parse()
返回一个
ElementTree
对象,而不是像字符串解析函数那样的
Element
对象:

print(type(tree))
# <class 'lxml.etree._ElementTree'>
>>> root = tree.getroot()
>>> print(root.tag)
root
>>> etree.tostring(root)
b'<root>data</root>'

这种差异背后的原因是parse()从文件返回一个完整的文档,而字符串解析函数通常用于解析XML片段。

parse()
函数支持以下任何来源:

  • 一个打开的文件对象(确保以二进制模式打开)
  • 一个具有
    .read(byte_count)
    方法的类文件对象,每次调用都返回一个字节字符串
  • 一个文件名字符串
  • 一个HTTP或FTP URL字符串

请注意,传递文件名或URL通常比传递打开的文件或类文件对象更快。然而,libxml2中的HTTP/FTP客户端相当简单,因此像HTTP认证这样的事情需要一个专门的URL请求库,例如urllib2或requests。这些库通常提供一个类文件对象作为结果,您可以在响应流式传输时从中解析。

Parser objects

默认情况下,lxml.etree使用具有默认设置的标准解析器。如果您想配置解析器,可以创建一个新实例:

parser = etree.XMLParser(remove_blank_text=True)  # lxml.etree only!

这创建了一个解析器,在解析时删除标签之间的空白文本,这可以减少树的大小,并避免在您知道空白内容对您的数据没有意义时出现悬挂的尾随文本。例如:

>>> root = etree.XML("<root>  <a/>   <b>  </b>     </root>", parser)

>>> etree.tostring(root)
b'<root><a/><b>  </b></root>'

请注意,
<b>
标签内的空白内容没有被移除,因为叶元素中的内容往往是数据内容(即使是空白的)。您可以通过遍历树来轻松地移除它:

for element in root.iter("*"):
    if element.text is not None and not element.text.strip():
        element.text = None

etree.tostring(root)
b'<root><a/><b/></root>'

请参阅
help(etree.XMLParser)
以了解有关可用解析器选项的信息。

help(etree.XMLParser)

Incremental parsing

lxml.etree提供了两种逐步增量解析的方法。一种是通过类文件对象,它反复调用read()方法。这最好用在数据来自像urllib这样的源或任何其他类文件对象(可以按请求提供数据)的地方。请注意,在这种情况下,解析器会阻塞并等待数据变得可用:

class DataSource:
    data = [ b"<roo", b"t><", b"a/", b"><", b"/root>" ]
    def read(self, requested_size):
        try:
            return self.data.pop(0)
        except IndexError:
            return b''

tree = etree.parse(DataSource())

etree.tostring(tree)
b'<root><a/></root>'

第二种方法是通过parser提供的feed(data)和close()方法:

parser = etree.XMLParser()

parser.feed("<roo")
parser.feed("t><")
parser.feed("a/")
parser.feed("><")
parser.feed("/root>")

root = parser.close()

etree.tostring(root)
b'<root><a/></root>'

在这里,你可以在任何时候中断解析过程,并在稍后通过再次调用
feed()
方法继续进行解析。这在你想要避免对解析器的阻塞调用时非常有用,例如在像 Twisted 这样的框架中,或者每当数据缓慢地或以块的形式到来,并且你在等待下一块数据时想要做其他事情的时候。

在调用close()方法(或解析器引发异常)之后,您可以通过再次调用其feed()方法来重用解析器:

parser.feed("<root/>")
root = parser.close()
etree.tostring(root)
b'<root/>'

Event-driven parsing

有时,您从文档中所需的只是树内部深处的一小部分,因此将整个树解析到内存中、遍历它然后丢弃它可能会有太多的开销。lxml.etree通过两种事件驱动的解析器接口支持这种用例,一种在构建树时生成解析器事件(
iterparse
),另一种根本不构建树,而是以类似SAX的方式在目标对象上调用反馈方法。

这里有一个简单的
iterparse()
示例:

some_file_like = BytesIO(b"<root><a>data</a></root>")

for event, element in etree.iterparse(some_file_like):
    print(f"{event}, {element.tag:>4}, {element.text}")
end,    a, data
end, root, None

默认情况下,iterparse()只在解析完一个元素时才生成一个事件,但您可以通过events关键字参数控制这一点:

some_file_like = BytesIO(b"<root><a>data</a></root>")

for event, element in etree.iterparse(some_file_like,
                                      events=("start", "end")):
    print(f"{event:>5}, {element.tag:>4}, {element.text}")
start, root, None
start,    a, data
  end,    a, data
  end, root, None

请注意,在接收
start
事件时,元素的文本、尾随文本和子元素不一定已经存在。只有
end
事件保证了元素已经被完全解析。

它还允许您使用
.clear()
方法或修改元素的内容以节省内存。因此,如果您解析了一个大的树,并且您希望保持内存使用量小,您应该清理不再需要的树的部分。
.clear()
方法的
keep_tail=True
参数确保当前元素后面的(尾随)文本内容不会被触动。强烈不建议修改解析器可能尚未完全读取的任何内容。

some_file_like = BytesIO(b"<root><a><b>data</b></a><a><b/></a></root>")

for event, element in etree.iterparse(some_file_like):
    if element.tag == 'b':
        print(element.text)
    elif element.tag == 'a':
        print("** cleaning up the subtree")
        element.clear(keep_tail=True)
data
** cleaning up the subtree
None
** cleaning up the subtree

iterparse()的一个非常重要的用例是解析大型生成的XML文件,例如数据库转储(database dumps)。最常见的情况是,这些XML格式只有一个主要的数据项元素直接挂在根节点下,并且这个元素会重复数千次。在这种情况下,最佳实践是让
lxml.etree
进行树的构建,并且只拦截这一个元素,使用正常的树API进行数据提取。

xml_file = BytesIO(b'''
<root>
  <a><b>ABC</b><c>abc</c></a>
  <a><b>MORE DATA</b><c>more data</c></a>
  <a><b>XYZ</b><c>xyz</c></a>
</root>''')

for _, element in etree.iterparse(xml_file, tag='a'):
    print('%s -- %s' % (element.findtext('b'), element[1].text))
    element.clear(keep_tail=True)
ABC -- abc
MORE DATA -- more data
XYZ -- xyz

如果出于某种原因,根本不希望构建树,可以使用lxml.etree的目标解析器接口(target parser interface)。它通过调用目标对象的方法创建类似SAX的事件。通过实现这些方法中的一些或全部,您可以控制生成哪些事件:

class ParserTarget:
    events = []
    close_count = 0
    def start(self, tag, attrib):
        self.events.append(('start', tag, attrib))
    def close(self):
        events, self.events = self.events, []
        self.close_count += 1
        return events

parser_target = ParserTarget()

parser = etree.XMLParser(target=parser_target)
events = etree.fromstring('<root test="true"/>', parser)

print(parser_target.close_count)
1
event: start - tag: root
 * test = true

您可以随心所欲地重用解析器及其目标,因此您应该确保
.close()
方法确实将目标重置为可用状态(即使在出错的情况下也是如此!)。

>>> events = etree.fromstring('<root test="true"/>', parser)
>>> print(parser_target.close_count)
2
>>> events = etree.fromstring('<root test="true"/>', parser)
>>> print(parser_target.close_count)
3
>>> events = etree.fromstring('<root test="true"/>', parser)
>>> print(parser_target.close_count)
4

>>> for event in events:
...     print(f'event: {event[0]} - tag: {event[1]}')
...     for attr, value in event[2].items():
...         print(f' * {attr} = {value}')
event: start - tag: root
 * test = true

Namespaces

只要有可能,ElementTree API 都避免使用
命名空间前缀
,而是使用真实的命名空间(URI):

>>> xhtml = etree.Element("{http://www.w3.org/1999/xhtml}html")
>>> body = etree.SubElement(xhtml, "{http://www.w3.org/1999/xhtml}body")
>>> body.text = "Hello World"

>>> prettyprint(xhtml)
<html:html xmlns:html="http://www.w3.org/1999/xhtml">
  <html:body>Hello World</html:body>
</html:html>

ElementTree使用的表示法最初由James Clark提出。它的主要优点是为标签提供了一个通用限定名称(universally qualified name),无论文档中可能已经使用或定义的任何前缀。通过将前缀的间接性(indirection of prefixes)移开,它使命名空间感知的代码更加清晰,更容易正确处理。

正如您从示例中看到的,前缀只在序列化结果时变得重要。然而,由于命名空间名称较长,上述代码看起来有些冗长。而且,一遍又一遍地重新键入或复制字符串容易出错。因此,通常的做法是将命名空间URI存储在全局变量中。为了调整(adapt)用于序列化的命名空间前缀,你也可以将一个映射传递给
Element
工厂函数,例如定义默认命名空间:

>>> XHTML_NAMESPACE = "http://www.w3.org/1999/xhtml"
>>> XHTML = "{%s}" % XHTML_NAMESPACE

>>> NSMAP = {None : XHTML_NAMESPACE} # the default namespace (no prefix)

>>> xhtml = etree.Element(XHTML + "html", nsmap=NSMAP) # lxml only!
>>> body = etree.SubElement(xhtml, XHTML + "body")
>>> body.text = "Hello World"

>>> prettyprint(xhtml)
<html xmlns="http://www.w3.org/1999/xhtml">
  <body>Hello World</body>
</html>

你也可以使用
QName
辅助类来构建或拆分限定的标签名称(qualified tag names)。

>>> tag = etree.QName('http://www.w3.org/1999/xhtml', 'html')
>>> print(tag.localname)
html
>>> print(tag.namespace)
http://www.w3.org/1999/xhtml
>>> print(tag.text)
{http://www.w3.org/1999/xhtml}html

>>> tag = etree.QName('{http://www.w3.org/1999/xhtml}html')
>>> print(tag.localname)
html
>>> print(tag.namespace)
http://www.w3.org/1999/xhtml

>>> root = etree.Element('{http://www.w3.org/1999/xhtml}html')
>>> tag = etree.QName(root)
>>> print(tag.localname)
html

>>> tag = etree.QName(root, 'script')
>>> print(tag.text)
{http://www.w3.org/1999/xhtml}script
>>> tag = etree.QName('{http://www.w3.org/1999/xhtml}html', 'script')
>>> print(tag.text)
{http://www.w3.org/1999/xhtml}script

lxml.etree允许您通过
.nsmap
属性查找为节点定义的当前命名空间:

>>> xhtml.nsmap
{None: 'http://www.w3.org/1999/xhtml'}

请注意,这包括在元素的上下文中已知的所有前缀,而不仅仅是它自己定义的那些。

root = etree.Element('root', nsmap={'a': 'http://a.b/c'})
child = etree.SubElement(root, 'child',
                         nsmap={'b': 'http://b.c/d'})
print(root.nsmap)
{'a': '[http://a.b/c](http://a.b/c)'}
len(root.nsmap)
# 1
print(child.nsmap)
{'b': '[http://b.c/d](http://b.c/d)', 'a': '[http://a.b/c](http://a.b/c)'}
len(child.nsmap)
child.nsmap['a']
# 'http://a.b/c'
child.nsmap['b']
# 'http://b.c/d'

因此,修改返回的字典对Element(元素)没有任何有意义的影响。对它的任何更改都会被忽略。

属性(attributes)上的命名空间工作方式类似,但自2.3版本起,lxml.etree将确保属性使用带有前缀的命名空间声明。这是因为XML命名空间规范(
第6.2节
)认为未加前缀的属性名称不处于任何命名空间中,因此即使它们出现在命名空间元素中,它们在序列化-解析循环中可能会丢失其命名空间。

body.set(XHTML + "bgcolor", "#CCFFAA")
prettyprint(xhtml)
<html xmlns="http://www.w3.org/1999/xhtml">
  <body xmlns:html="http://www.w3.org/1999/xhtml" html:bgcolor="#CCFFAA">Hello World</body>
</html>
# XML命名空间规范认为未加前缀的属性名称不处于任何命名空间中,所以返回None
print(body.get("bgcolor"))
None
# 使用加上前缀的属性名称
body.get(XHTML + "bgcolor")
'#CCFFAA'

您还可以使用完全限定的名称(fully qualified names)来使用XPath:

# 先回顾一下xhtml的内容
print(etree.tostring(xhtml).decode())
<html xmlns="http://www.w3.org/1999/xhtml"><body xmlns:html="http://www.w3.org/1999/xhtml" html:bgcolor="#CCFFAA" bgcolor="#CCFFAA">Hello World</body></html>
>>> find_xhtml_body = etree.ETXPath(      # lxml only !
...     "//{%s}body" % XHTML_NAMESPACE)
>>> results = find_xhtml_body(xhtml)

>>> print(results[0].tag)
{http://www.w3.org/1999/xhtml}body

为了方便,您可以在lxml.etree的所有迭代器中使用"
*
"通配符,无论是对于标签名称还是命名空间:

>>> for el in xhtml.iter('*'): print(el.tag)   # any element
{http://www.w3.org/1999/xhtml}html
{http://www.w3.org/1999/xhtml}body

>>> for el in xhtml.iter('{http://www.w3.org/1999/xhtml}*'): print(el.tag)
{http://www.w3.org/1999/xhtml}html
{http://www.w3.org/1999/xhtml}body

>>> for el in xhtml.iter('{*}body'): print(el.tag)
{http://www.w3.org/1999/xhtml}body

要查找没有命名空间的元素,请使用纯标签名称,或明确提供空的命名空间:

>>> [ el.tag for el in xhtml.iter('{http://www.w3.org/1999/xhtml}body') ]
['{http://www.w3.org/1999/xhtml}body']
>>> [ el.tag for el in xhtml.iter('body') ]
[]
>>> [ el.tag for el in xhtml.iter('{}body') ]
[]
>>> [ el.tag for el in xhtml.iter('{}*') ]
[]

The E-factory

E-factory提供了一种简单紧凑的语法,用于生成XML和HTML:

from lxml.builder import E

def CLASS(*args):    # class is a reserved word in Python
    return {"class":' '.join(args)}

html = page = (
    E.html(
        E.head(
            E.title("This is a sample document")
        ),
        E.body(
            E.h1("Hello!", CLASS("title")),
            E.p("This is a paragraph with ", E.b("bold"), " text in it!"),
            E.p("This is another paragraph, with a", "\n      ",
                E.a("link", href="http://www.python.org"), "."),
            E.p("Here are some reserved characters: <spam&egg>."),
            etree.XML("<p>And finally an embedded XHTML fragment.</p>"),
        )
    )
)

prettyprint(page)
<html>
  <head>
    <title>This is a sample document</title>
  </head>
  <body>
    <h1 class="title">Hello!</h1>
    <p>This is a paragraph with <b>bold</b> text in it!</p>
    <p>This is another paragraph, with a
      <a href="http://www.python.org">link</a>.</p>
    <p>Here are some reserved characters: &lt;spam&amp;egg&gt;.</p>
    <p>And finally an embedded XHTML fragment.</p>
  </body>
</html>

基于属性访问的元素创建使得为XML 语言构建一种简单的词汇表变得容易。

from lxml.builder import ElementMaker  # lxml only!

E = ElementMaker(namespace="http://my.de/fault/namespace", nsmap={'p': "http://my.de/fault/namespace"})

DOC = E.doc
TITLE = E.title
SECTION = E.section
PAR = E.par

my_doc = DOC(
    TITLE("The dog and the hog"),
    SECTION(
        TITLE("The dog"),
        PAR("Once upon a time, ..."),
        PAR("And then ...")
    ),
    SECTION(
        TITLE("The hog"),
        PAR("Sooner or later ...")
    )
)

prettyprint(my_doc)
<p:doc xmlns:p="http://my.de/fault/namespace">
  <p:title>The dog and the hog</p:title>
  <p:section>
    <p:title>The dog</p:title>
    <p:par>Once upon a time, ...</p:par>
    <p:par>And then ...</p:par>
  </p:section>
  <p:section>
    <p:title>The hog</p:title>
    <p:par>Sooner or later ...</p:par>
  </p:section>
</p:doc>

一个这样的例子是模块lxml.html.builder,它为HTML提供了一个词汇表。

当处理多个命名空间时,最佳实践是为每个命名空间URI定义一个ElementMaker。再次注意,上述示例如何在命名常量中预定义了标签构建器(tag builders)。这使得将一个命名空间的所有标签声明放入一个Python模块,并从那里导入/使用标签名称常量变得容易。这避免了诸如拼写错误或意外遗漏命名空间之类的陷阱。

ElementPath

ElementTree库附带了一个简单的类似XPath的路径语言,称为ElementPath。主要区别在于您可以在ElementPath表达式中使用{namespace}tag表示法。然而,高级特性如值比较和函数是不可用的。

除了
完整的XPath实现
,lxml.etree以与ElementTree相同的方式支持ElementPath语言,甚至使用(几乎)相同的实现。API在这里提供了四种方法,您可以在Elements和ElementTrees上找到这些方法:

  • iterfind()
    遍历所有匹配路径表达式(path expression)的元素。
  • findall()
    返回匹配元素的列表。
  • find()
    高效地仅返回第一个匹配项。
  • findtext()
    返回第一个匹配项的
    .text
    内容。

这里有一些示例:

root = etree.XML("<root><a x='123'>aText<b/><c/><b/></a></root>")

查找元素的子元素:

>>> print(root.find("b"))
None
>>> print(root.find("a").tag)
a

在树中查找元素:

>>> print(root.find(".//b").tag)
b
>>> [ b.tag for b in root.iterfind(".//b") ]
['b', 'b']

查找具有特定属性的元素:

>>> print(root.findall(".//a[@x]")[0].tag)
a
>>> print(root.findall(".//a[@y]"))
[]


lxml
3.4 版本中,有一个新的辅助函数用于为一个
Element
生成结构化的
ElementPath
表达式。

>>> tree = etree.ElementTree(root)
>>> a = root[0]
>>> print(tree.getelementpath(a[0]))
a/b[1]
>>> print(tree.getelementpath(a[1]))
a/c
>>> print(tree.getelementpath(a[2]))
a/b[2]
>>> tree.find(tree.getelementpath(a[2])) == a[2]
True

只要树未被修改,这个路径表达式就代表给定元素的标识符,可以稍后在相同树中使用find()找到它。与XPath相比,ElementPath表达式的优势在于即使对于使用命名空间的文档,它们也是自包含的。

.iter()
方法是一个特例,它仅通过名称在树中查找特定标签,而不是基于路径。这意味着在成功的情况下,以下命令是等效的:

>>> print(root.find(".//b").tag)
b
>>> print(next(root.iterfind(".//b")).tag)
b
>>> print(next(root.iter("b")).tag)
b

本篇是 Python 系列教程第 13 篇,更多内容敬请访问我的 Python 合集

Python 装饰器是一种强大的工具,用于修改或增强函数或方法的行为,而无需更改其源代码。装饰器本质上是一个接收函数作为参数的函数,并返回一个新的函数。装饰器的用途包括日志记录、性能测试、事务处理、缓存、权限校验等

1 基本语法

装饰器的基本语法是在函数定义之前使用
@
符号,紧跟着装饰器的名字。例如:

# 定义一个装饰器,参数为被装饰的方法
def my_decorator(func):
    def wrapper():
        print("方法运行前")
        func()
        print("方法运行后")

    return wrapper

# 用“@”使用装饰器
@my_decorator
def say_hello():
    print("Hello!")

say_hello()

这段代码会输出:

方法运行前
Hello!
方法运行后

2 参数传递

如果被装饰的函数需要参数,装饰器也需要相应地处理这些参数:

def my_decorator(func):
    def wrapper(name):
        print("方法运行前")
        func(name)
        print("方法运行后")

    return wrapper

@my_decorator
def greet(name):
    print(f"Hello, {name}!")

greet("Alice")

输出:

方法运行前
Hello, Alice!
方法运行后

参数可以用可变参数,比较灵活,如下:

def my_decorator(func):
    def wrapper(*args, **kwargs):
        print("方法运行前")
        func(*args, **kwargs)
        print("方法运行后")

    return wrapper

@my_decorator
def greet(name):
    print(f"Hello, {name}!")

greet("Alice")

3 使用多个装饰器

你可以为同一个函数使用多个装饰器:

def decorator_A(func):
    print("enter A")
    def wrapper():
        print("Before A")
        func()
        print("After A")
    print("out A")
    return wrapper
 
def decorator_B(func):
    print("enter B")
    def wrapper():
        print("Before B")
        func()
        print("After B")
    print("out B")
    return wrapper
 
@decorator_A
@decorator_B
def my_function():
    print("Inside my_function")
 
# 执行被装饰器装饰的函数
my_function()

输出:

enter B
out B
enter A
out A
Before A
Before B
Inside my_function
After B
After A

注意打印结果的顺序。

为了方便表达我们先把靠近被修饰方法的装饰器叫内层装饰器,如示例中的
@decorator_B
,不靠近的叫外层装饰器,如示例中的
@decorator_A

在闭包
wrapper
外面的代码是内层装饰器先执行,在闭包
wrapper
内部的代码执行顺序复杂一些:
①外层装饰器先执行
func()
前面的代码->②内层装饰器执行
func()
前面的代码->③执行
func()
->④内层装饰器执行
func()
后面的代码->⑤外层装饰器执行
func()
后面的代码。

4 给装饰器传参

装饰器本身可以接受参数,可以根据传入的不同参数来改变装饰器的行为。

前面的例子都是没有参数的装饰器,如果我们想要给装饰器传参该怎么办呢?于是我们就思考一下,什么东东可以接收参数呢,答案是函数。bingo!Python也是这样设计的,我们只需要在装饰器外面包裹一层函数,就可以把参数传递给函数进而传递给装饰器了。

可以这样定义装饰器:

def repeat(num_times):
    def decorator_repeat(func):
        def wrapper(*args, **kwargs):
            for _ in range(num_times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator_repeat

@repeat(3)
def greet(name):
    print(f"Hello, {name}!")

greet("Alice")

输出:

Hello, Alice!
Hello, Alice!
Hello, Alice!

这样就定义了一个根据传入装饰器的数值执行指定次数函数的装饰器。

5 类作为装饰器

5.1
__call__
方法

装饰器不仅仅可以是方法,也可以是类。这就不得不介绍一个特殊的方法
__call__

Python的类只要实现了
__call__
这个特殊方法,类的实例对象就可以像函数一样被调用,因为当尝试把对象写成方法调用的写法时(名称+()),Python 解释器会查找该对象的
__call__
方法并调用它。

下面来看一个简单的例子,演示
__call__
的使用:

class Counter:
    def __init__(self):
        self.count = 0

    def __call__(self, *args, **kwargs):
        self.count += 1
        print(f"方法被调用了 {self.count} 次")

counter = Counter()

# 模拟调用
counter()
counter()
counter()

打印:

方法被调用了 1 次
方法被调用了 2 次
方法被调用了 3 次

5.2 类作为装饰器

类作为装饰器的一个主要优势是可以方便地维护状态,因为类可以有实例变量。

理解了
__call__
之后,我们可以想到类作为装饰器的原理是在类里实现了
__call__
方法,使得装饰器的代码可以被执行。

下面我们定义一个记录函数调用次数的装饰器:

class CallCounter:
    def __init__(self, func):
        self.func = func
        self.count = 0

    def __call__(self, *args, **kwargs):
        self.count += 1
        print(f"{self.func.__name__} 被调用了 {self.count} 次")
        return self.func(*args, **kwargs)

@CallCounter
def say_hello(name):
    print(f"Hello, {name}!")

# 调用被装饰的函数
say_hello("Alice")
say_hello("Bob")
say_hello("Charlie")

# 输出
# say_hello 被调用了 1 次
# Hello, Alice!
# say_hello 被调用了 2 次
# Hello, Bob!
# say_hello 被调用了 3 次
# Hello, Charlie!

代码解释:

  1. CallCounter
    类有一个构造函数
    __init__
    ,它必须接受一个函数作为参数。
  2. 类实现了
    __call__
    方法,这使得其实例可以像函数一样被调用。

  3. __call__
    方法中,每次调用被装饰的函数时,都会增加计数器
    count
    的值,并打印出函数被调用的次数。
  4. 最后,
    __call__
    方法调用了原始函数
    self.func
    并返回结果。

我们在定义SQLAlchemy对象模型的关系的时候,用到了relationship 来标识关系,其中 lazy 的参数有多种不同的加载策略,本篇随笔介绍它们之间的关系,以及在异步处理中的一些代码案例。

1、在 SQLAlchemy 中定义关系

在 SQLAlchemy 中,
relationship()
函数用于定义表之间的关系(如
one-to-many

many-to-one

many-to-many
等)。它支持许多参数来控制如何加载和处理关联的数据。以下是一些常用的
relationship()
参数及其说明:

1.
lazy

  • 作用
    : 控制如何加载关联数据。
  • 可选值
    :
    • 'select'
      : 延迟加载。访问关系属性时,发送一个独立的查询来获取关联数据(默认值)。
    • 'selectin'
      : 使用
      IN
      查询批量加载关联对象,避免 n+1 查询问题。
    • 'joined'
      : 使用
      JOIN
      直接在主查询中加载关联数据。
    • 'subquery'
      : 使用子查询来批量加载关联对象。
    • 'immediate'
      : 在加载主对象后,立即加载关联对象。
    • 'dynamic'
      : 仅适用于
      one-to-many
      ,返回一个查询对象,可以进一步过滤或操作关联数据。
  • 详细说明

在SQLAlchemy中,
lazy
是一个定义ORM关系如何加载的参数,主要用于控制关联关系(如
one-to-many

many-to-one
等)在访问时的加载方式。

1)
lazy='select'
(默认)

  • 说明
    : 这是最常见的方式,使用"延迟加载"策略。当访问关联属性时,SQLAlchemy会发送一条新的SQL查询来加载相关数据。
  • 优点
    : 避免了不必要的查询,节省资源。
  • 缺点
    : 当你访问多个关联对象时,可能会导致"n+1查询问题",即每次访问关联数据时都会发出新的SQL查询。

2)
lazy='selectin'

  • 说明
    : 类似于
    lazy='select'
    ,但通过
    IN
    语句批量查询相关对象。SQLAlchemy会在一次查询中批量获取多个对象的关联数据,而不是为每个对象单独查询。
  • 优点
    : 解决了"n+1查询问题",效率高于
    select
  • 缺点
    : 适用于可以通过
    IN
    语句高效查询的场景,但如果结果集非常大,可能会影响性能。

3)
lazy='joined'

  • 说明
    : 在主查询时,使用
    JOIN
    语句直接加载关联对象。这意味着关联对象在查询时就会被立即加载,不需要额外的查询。
  • 优点
    : 避免了多个SQL查询,适合在同一查询中需要大量关联数据的场景。
  • 缺点
    : 如果
    JOIN
    的表数据较多,可能会导致查询结果变得复杂且性能下降。

4)
lazy='immediate'

  • 说明
    : 在加载主对象时,立即加载所有关联对象。与
    select
    类似,但是在主对象加载后,马上发送查询请求加载关联对象。
  • 优点
    : 保证在对象加载后立刻有完整的数据。
  • 缺点
    : 对每个关联的对象仍然会发送单独的查询,可能造成"n+1查询问题"。

5)
lazy='subquery'

  • 说明
    : 使用子查询来加载关联对象。SQLAlchemy会在查询主对象时生成一个子查询,以批量加载相关对象。
  • 优点
    : 避免了"n+1查询问题",适合处理大型数据集。
  • 缺点
    : 子查询可能会导致查询效率降低,特别是在复杂的查询场景中。

6)
lazy='dynamic'

  • 说明
    : 仅适用于
    one-to-many
    关系,返回一个查询对象,而不是实际的结果集。你可以通过调用查询对象来进一步过滤或操作关联对象。
  • 优点
    : 非常灵活,可以根据需要随时查询关联对象。
  • 缺点
    : 不能使用通常的方式访问关联属性,必须通过查询进一步获取数据。

2.
backref

  • 作用
    : 定义反向引用,允许从关联表访问当前表。

  • 用法
    : 通过
    backref
    ,可以在关联的表中自动生成一个反向关系,避免手动定义双向关系。

  • 示例
    :

classParent(Base):__tablename__ = 'parent'id= Column(Integer, primary_key=True)
children
= relationship("Child", backref="parent")classChild(Base):__tablename__ = 'child'id= Column(Integer, primary_key=True)
parent_id
= Column(Integer, ForeignKey('parent.id'))

3.
back_populates

  • 作用
    : 手动定义双向关系时,使用
    back_populates
    来明确地表示两个表之间的相互关系。

  • 示例
    :

classParent(Base):__tablename__ = 'parent'id= Column(Integer, primary_key=True)
children
= relationship("Child", back_populates="parent")classChild(Base):__tablename__ = 'child'id= Column(Integer, primary_key=True)
parent_id
= Column(Integer, ForeignKey('parent.id'))
parent
= relationship("Parent", back_populates="children")

4.
cascade

  • 作用
    : 定义级联操作,决定在父对象上进行操作时,是否自动对关联的子对象进行相应操作。

  • 常见值
    :


    • 'save-update'
      : 当父对象被保存或更新时,子对象也会被保存或更新。
    • 'delete'
      : 当父对象被删除时,子对象也会被删除。
    • 'delete-orphan'
      : 当子对象失去与父对象的关联时,子对象将被删除。
    • 'all'
      : 包含所有级联操作。
  • 示例
    :

children = relationship("Child", cascade="all, delete-orphan")

5.
uselist

  • 作用
    : 控制关联属性是否返回一个列表。适用于
    one-to-one

    one-to-many
    关系。

  • 用法
    :


    • True
      : 返回一个列表(适用于
      one-to-many
      ,默认值)。
    • False
      : 返回单个对象(适用于
      one-to-one
      )。
  • 示例
    :

parent = relationship("Parent", uselist=False)  #one-to-one 关系

6.
order_by

  • 作用
    : 定义关联对象的排序方式。

  • 示例
    :

children = relationship("Child", order_by="Child.name")

7.
foreign_keys

  • 作用
    : 显式指定哪些列是用于定义关联关系的外键,适用于存在多个外键的场景。

  • 示例
    :

parent = relationship("Parent", foreign_keys="[Child.parent_id]")

8.
primaryjoin

  • 作用
    : 明确定义关联关系的连接条件,通常在 SQLAlchemy 无法自动推断时使用。

  • 示例
    :

parent = relationship("Parent", primaryjoin="Parent.id == Child.parent_id")

9.
secondary

  • 作用
    : 定义多对多(
    many-to-many
    )关系时,指定关联的中间表。

  • 示例
    :

classAssociation(Base):__tablename__ = 'association'parent_id= Column(Integer, ForeignKey('parent.id'))
child_id
= Column(Integer, ForeignKey('child.id'))

children
= relationship("Child", secondary="association")

10.
secondaryjoin

  • 作用
    : 定义
    secondary
    表中的关联条件,通常用于复杂的多对多关系。

  • 示例
    :

children = relationship("Child", secondary="association", 
secondaryjoin
="Child.id == Association.child_id")

11.
viewonly

  • 作用
    : 定义只读的关系,不允许通过此关系修改数据。

  • 示例
    :

children = relationship("Child", viewonly=True)

12.
passive_deletes

  • 作用
    : 控制删除时的行为。如果设置为
    True
    ,SQLAlchemy 不会主动删除关联对象,而是依赖数据库的级联删除。

  • 示例
    :

children = relationship("Child", passive_deletes=True)

这些参数可以根据具体的业务需求和场景进行调整,以优化查询和数据管理策略。

2、用户角色表的关系分析

在实际业务中,机构和用户是多对多的关系的,我们以机构表定义来进行分析它们的关系信息。

如机构表的模型定义大致如下。

classOu(Base):"""机构(部门)信息-表模型"""
    __tablename__ = "t_acl_ou"id= Column(Integer, primary_key=True, comment="主键", autoincrement=True)
pid
= Column(Integer, ForeignKey("t_acl_ou.id"), comment="父级机构ID", default="-1")
handno
= Column(String, comment="机构编码")
name
= Column(String, comment="机构名称")#定义 parent 关系 parent =relationship("Ou", remote_side=[id], back_populates="children", lazy="immediate")#定义 children 关系 children = relationship("Ou", back_populates="parent", lazy="immediate")#定义 users 关系 users =relationship("User", secondary="t_acl_ou_user", back_populates="ous", lazy="select")

我们可以看到其中加载的多对多关系是采用lazy=select的方式的。

当你使用
await session.get(Ou, ou_id)
来获取一个
Ou
对象后,访问其关系属性(如
ou.users
)时,可能会遇到异步相关的问题。原因是,SQLAlchemy 的异步会话需要使用
selectinload
或其他异步加载选项来确保在异步环境中正确地加载关联数据。

在默认的
lazy='select'
关系中,加载关系对象会触发一个同步查询,而这与异步会话不兼容,导致错误。为了解决这个问题,你需要确保关系的加载是通过异步的方式进行的。

解决方法:

1. 使用
selectinload
进行预加载

在查询时,显式地通过
selectinload
来加载关联的
users
关系:

from sqlalchemy.orm importselectinload

ou
= await session.get(Ou, ou_id, options=[selectinload(Ou.users)])#现在你可以访问 ou.users,关系对象已经被异步加载 print(ou.users)

2. 使用
lazy='selectin'
或其他异步兼容的加载策略

你还可以在定义模型的关联关系时,将
lazy='selectin'
设置为默认的加载方式,这样当访问关联属性时,SQLAlchemy 会自动使用异步兼容的加载机制:

classOu(Base):__tablename__ = 'ou'id= Column(Integer, primary_key=True)
users
= relationship("User", lazy='selectin') #使用 selectin 异步加载 ou=await session.get(Ou, ou_id)print(ou.users) #关联对象可以正常异步访问

总结:

  • 在异步环境中访问关系对象时,如果使用了同步的
    lazy='select'
    ,会导致异步不兼容问题。
  • 解决方案是通过查询时使用
    selectinload
    或将关系的
    lazy
    属性设置为异步兼容的选项,如
    selectin

因此,如果机构和用户的关系信息,我们可以通过selectload关系实现加载,也可以考虑使用中间表的关系进行获取,如下代码所示:获取指定用户的关联的机构列表.

    async def get_ous_by_user(self, db: AsyncSession, user_id: str) ->list[int]:"""获取指定用户的关联的机构列表"""
        #方式一,子查询方式
        stmt = select(User).options(selectinload(User.ous)).where(User.id ==user_id)
result
=await db.execute(stmt)
user
=result.scalars().first()
ous
= user.ous if user else[]#方式二,关联表方式 #stmt = ( #select(Ou) #.join(user_ou, User.id == user_ou.c.user_id) #.where(user_ou.c.user_id == user_id) #) #result = await db.execute(stmt) #ous = result.scalars().all() ouids= [ou.id for ou inous]return ouids

上面两种方式是等效的,一个是通过orm关系进行获取关系集合,一个是通过中间表的关系检索主表数据集合。

通过中间表,我们也可以很方便的添加角色的关系,如下面是为角色添加用户,也就是在中间表进行处理即可。

    async def add_user(self, db: AsyncSession, role_id: int, user_id: int) ->bool:"""添加角色-用户关联"""stmt=select(user_role).where(
and_(
user_role.c.role_id
==role_id,
user_role.c.user_id
==user_id,
)
)
if not(await db.execute(stmt)).scalars().first():
await db.execute(
user_role.insert().values(role_id
=role_id, user_id=user_id)
)
await db.commit()
returnTruereturn False

当然。如果我们不用这种中间表的处理方式,也是可以使用常规多对多关系进行添加处理,不过需要对数据进行多一些检索,也许性能会差一些。

    async def add_user(self, db: AsyncSession, ou_id: int, user_id: int) ->bool:"""给机构添加用户"""
        #可以使用下面方式,也可以使用中间表方式处理
        #先判断用户是否存在
        user =await db.get(User, user_id)if notuser:returnFalse#再判断机构是否存在
        result =await db.execute(
select(Ou).options(selectinload(Ou.users)).filter_by(id
=ou_id)
)
#await db.get(Ou, ou_id) #这种方式不能获得users,因为配置为selectin #await db.get(Ou, ou_id, options=[selectinload(Ou.users)]) # 这种方式可以获得users ou =result.scalars().first()if notou:returnFalse#再判断用户是否已经存在于机构中 if user inou.users:returnFalse#加入机构 ou.users.append(user)
await db.commit()
return True

前言

推荐一款基于.NET 8、WPF、Prism.DryIoc、MVVM设计模式、Blazor以及MySQL数据库构建的企业级工作流系统的WPF客户端框架-AIStudio.Wpf.AClient 6.0。

项目介绍

框架采用了 Prism 框架来实现 MVVM 模式,不仅简化了 MVVM 的典型应用场景,还充分利用了依赖注入(DI)、消息传递以及容器管理的优势。

网上有很多标准的MVVM的使用方法,但是没有形成一个系统级的框架。本框架从登录到具体业务的使用,还有自动升级都搭建完成。

后端使用ASP.NET Core,采用的是AIStudio.Blazor.App的框架(与BS使用相同后台)。

另外框架还引入了面向切面编程(AOP)和模型关联映射(MAP)等高级特性,进一步增强了系统的扩展性和灵活性。

功能模块

1、自动更新软件。

2、使用 Prism.DryIoc 而非 Prism.Unity。

3、用 Prism 实现 AvalonDock 步骤。

4、通过 AOP 记录日志。

5、代码生成器的设计思路。

6、工作流实现策略(含编辑器和后端)。

7、创建自定义安装界面的安装包方法。

8、本地服务启动方法。

9、通用 CRUD 配置,无需定义类,直接在数据库中添加数据。

10、实现拖拽编程。

11、与BS(blazor)使用相同的结构模式,如果BS与CS进行代码统一。

12、Prism 区域窗口多开与区域注册隔离实现。

快速预览

WPF客户端下载可以直接运行,默认配置文件 AIStudio.Wpf.Client.exe.Config

<appSettings>
    <addkey="Title"value="AIStudio" />
    <addkey="Language"value="中文" />
    <addkey="FontSize"value="16" />
    <addkey="FontFamily"value="宋体" />
    <addkey="Accent"value="BlueGray" />
    <addkey="Theme"value="BaseGray11" />
    <addkey="NavigationLocation"value="Left" />
    <addkey="NavigationAccent"value="Dark" />
    <addkey="TitleAccent"value="Normal" />
    <addkey="ToolBarLocation"value="Top" />
    <addkey="Version"value="1.0.20201115-rc3" />
    <addkey="ServerIP"value="http://localhost:5000/" />
    <addkey="UpdateAddress"value="http://localhost:5000//update" />
 </appSettings>

1、快速预览方式1

其中ServerIP就是后台接口地址。

账号密码:Admin,Admin。

2、快速预览方式2

不需要服务器,客户端直接使用SQLite本地数据,客户端独立运行。

账号密码Admin, Admin

<addkey="ServerIP"value=""/> 
<addkey="UpdateAddress"value="http://localhost:5000/Update/AutoUpdater.xml"/>
<addkey="ConString"value="Data Source=Admin.db"/>
<addkey="DatabaseType"value="SQLite"/>
<addkey="DeleteMode"value="Logic"/>

注释掉ServerIP,那么是使用efcore获取数据,改变ConString和DatabaseType即可。另外,默认数据库删除模式为软删除。

3、快速预览方式3

启动ServiceMonitor,点击启动服务,待本地服务启动后,可运行客户端进行连接。

<addkey="ServerIP"value="http://localhost:5000" />

快速预览方式可直接在登录界面进行切换。

项目框架

6.0的框架如下

系统扩展 :如果需要扩展自己的页面,只需要按照这个工程的目录进行扩展即可。

项目功能

1、快速代码生成

在数据库添加新表。

选择代码生成菜单,选中查询回来的新表,区域为你所加界面的工程,比如默认值Base_Manage,将把页面加到AIStudio.Wpf.Base_Manage工程下。

点击生成即可,重新启动客户端即可快速预览(前提是服务端也用代码生成器生成(在web端的代码生成器)了相应的控制器与接口)

2、大屏界面(可拖拽,可全屏)

3、Form 表单

表单-代码生成,是代码生成器的一种补充。

4、通用crud方法

读取数据库配置,生成DataGrid,完全不需要类,后台完成相关接口,前台不需要更改任何代码,只需要在数据库插入脚本即可。

根据类直接生成DataGrid

5、大文件上传与下载

6、多窗口、多屏模式

项目地址

Github:
https://github.com/akwkevin/aistudio.-wpf.-aclient

Gitee:
https://gitee.com/akwkevin/aistudio.-wpf.-aclient

控件库:
https://gitee.com/akwkevin/AI-wpf-controls

最后

如果你觉得这篇文章对你有帮助,不妨点个赞支持一下!你的支持是我继续分享知识的动力。如果有任何疑问或需要进一步的帮助,欢迎随时留言。

也可以加入微信公众号
[DotNet技术匠]
社区,与其他热爱技术的同行一起交流心得,共同成长!
优秀是一种习惯,欢迎大家留言学习!

作者:来自 vivo 互联网服务器团队- Wang Fei

单点登录作为公共组件,在各个公司内部被各个系统所广泛使用,但是在使用过程中我们会遇到各种各样的问题,其中循环登录问题就是一个比较经典的问题。本文主要分析单点登录和权限系统设计的基本原理,然后结合实际案例来分析循环登录的原因,并给出具体的解决办法。

一、单点登录简单介绍

1.1 基本概念

一个公司内部可能存在多个系统,如果每一个人在使用不同系统的时候都需要重新登录,那么会做大量系统登录切换、耗费比较多的精力去管理账号和密码,那么有没有办法在一个公司内部的所有系统只需要一次登录验证,后续使用其他系统的时候不用重复登录就可以直接使用呢,这就是单点登录要解决的问题。

单点登录英文全称 Single Sign On(SSO),允许用户一次登录即可访问多个应用程序或系统,无需为每个应用程序或系统分别输入认证凭据,便可在其他所有系统中得到授权,无需再次登录。

1.2 基本实现原理

  • 用户登录:用户在任何一个应用程序或系统中进行身份验证,并提供他们的凭据。
  • 认证系统验证:该凭据被发送到认证系统进行验证。如果凭据有效,则认证系统会为用户生成数字签名的令牌(如 Token 或 Ticket)。
  • 令牌分发:认证系统将令牌返回给应用程序或子系统。
  • 应用程序或系统授权:应用程序或系统使用令牌验证用户的身份,并授权其访问相应资源或服务。
  • 跨域系统访问:用户可以通过同一令牌访问多个跨域应用程序或系统,而无需重复进行身份验证。

二、循环登录问题

在某一天我们登录一个内部系统时,突然出现了循环登录问题,前端页面不断刷新,提示“重定向次数过多问题”。

打开前端调试功能, 我们会发现确实存在大量重定向请求的问题:

那么平时登录没问题的系统为什么突然间就循环登录呢?并在页面上提示的解决方法“尝试删除您的 Cookie 操作”,按照这个操作以后,确实系统又可以跳转到登录页面正常进行登录了,这又是什么原因?下面我们将逐一分析。

三、从一次正常登录流程说起

上述是一个通用的系统权限管控和单点系统认证的标准流程:

  • 用户第一次访问时,在浏览器输入 https://aaa.x.y  回车
  • 权限系统进行拦截,判断用户是否登录,这里主要是通过是否有登录信息判断,如果没有登录,权限系统会帮我们跳转到单点登录系统,弹出用户登录页。
  • 用户填写用户名、密码,单点登录系统进行认证后,将登录状态写入 SSO 的 session。
  • SSO 系统登录完成后会给我们的系统生成一个 Token ,然后跳转到我们的系统,同时将 Token 作为参数传递给我们的系统。
  • 我们系统拿到 Token 后,从后台向 SSO 发送请求,验证 Token 是否有效。
  • 验证通过后,我们系统将记录顶级域下的 Cookie 信息。
Connection: keep-alive
Content-Length: 0
Date: Wed, 25 Oct 2023 08:29:43 GMT
Location: http://aaa.x.y/console/login/auth?redirectUrl=http://aaa.x.y/
optrace: xx.xx.xx.xx:80/302 <- -
Server: nginx
Set-Cookie: token=fakdfajdfdjfdjkfaldfjk'afafjasfasfa; Max-Age=86400; Expires=Thu, 26-Oct-2023 08:29:43 GMT; Domain=x.y; Path=/; HttpOnly

一个公司内部一般情况下只有一个域名,通过二级域名区分不同的系统。比如我们有个域名叫做:x.y ,同时有两个业务系统分别为:app1.x.y 和 app2.x.y。SSO 登录以后,可以将 Cookie 的域设置为顶域,即 x.y ,这样所有子域的系统都可以访问到顶域的 Cookie ,实现单点登录功能。

四、循环登录产生的根本原因

那么为什么会不断循环登录呢?

(1)从跳转记录来看,我们发现重新刷新页面以后,重定向到了权限系统,并且 Request Headers 中的 Cookie 信息没有传对应的 Token 信息。

(2)跳转到权限系统过后,再跳转到本系统的时候,已经获取到了对应的 Token 信息,但是在 Set-Cookie 信息的时候,出现了一个警告。

警告的具体内容为:

大致意思是:这次执行 Set-Cookie 操作被阻止了,原因是这个 Cookie 不是通过安全的连接进行传输的,我们这次访问确实使用了 HTTP 进行,本应该通过设置   Secure 属性来覆盖对应的 Cookie。

这里的 Secure 属性是 Cookie 的一个属性,Secure 属性是说如果一个 Cookie 被设置了 Secure = true,那么这个 Cookie 只能用 HTTPS 协议发送给服务器,用 HTTP 协议是不发送的,而我们查看上面标注位置的下一个 Login 请求确实没有传 Cookie 信息,从而继续进行用户是否登录校验,进入死循环过程, 可以看下面的示意图:

五、清除浏览器缓存的底层原理及解决方法

5.1 清除浏览器缓存的底层原理

我们可以看到循环登录以后,会在浏览器页面上提示 xxx.x.y 重定向次数过多,尝试清除 Cookie 信息,那清除 Cookie 信息以后,是不是真的就可以解决这个问题呢,我们尝试着清除浏览器缓存,确实可以解决这个问题,那清除浏览器缓存来解决循环登录问题的底层原理是什么呢,其实质就是将 Cookie 删除,其他域名设置 Cookie 上的 Secure 属性也就一并删除了,从而使用 HTTP 域名进入重新登录流程,可以正常设置 Cookie 信息。

5.2 其他解决办法

方法一:使用 HTTPS 的方式进行访问
现实使用中,我们无法控制其他 HTTPS 访问的具有相同顶级域名的服务不去设置 Cookie 的 Secure 属性,因而我们在后面使用的过程中还是会遇到这个问题,那么有没有一种彻底的解决办法能够避免这个问题再次出现,我们前面已经分析,之所以我们在开始使用 HTTP 能够进行正常访问,然后突然间不能正常访问了,就是因为已经被 HTTPS 设置了的 Cookie 信息是无法被 HTTP 重新设置的,从而拿不到 Cookie 信息。那么就出现了第一种解决办法,使用 HTTPS 的方式进行访问,即使其他服务设置了 Cookie 的 Secure 属性,用 HTTPS 仍然能够成功设置 Cookie 和获取 Cookie。

方法二:在 Cookie 信息中新设置一个 newToken
以上从 HTTP 转为 HTTPS 访问的方法在用户主动找我们反馈时是能够告诉它切换为 HTTPS 访问的,但是如果对于一些没有主动找我们反馈的用户,其实是无法解决,可能丧失这个用户造成用户流失的情况,那么我们在用户不进行切换的情况下是否能够解决这个问题。

同一个公司内部接入的权限系统是一种底层公共能力,为了保证单点登录,其实用户信息的读取都是通过同一个 Cookie 参数(比如叫 Token )读取的,那么在其他域名设置了公共 Cookie 参数的 Secure 属性而影响到 HTTP 登录的时候,我们可以给服务新增加一个 Cookie 参数 newToken 去解决。

六、扩展: Cookie 的端口不隔离性

本文所阐述的问题,出现的背景是有两个基本前提的:一是为了保证单点登录,两个域名属于同一个顶级域名,权限系统中关于用户信息的校验都是通过同一个 Cookie 属性去读取的;第二个是 HTTPS 设置了顶级域名的 Cookie 信息的 Secure 属性,然后使用 HTTP 访问会导致循环登录。有些开发者可能会有这样一个疑问,那就是 HTTPS 我们一般开通的端口是 443 , HTTP 我们一般开通的端口是 8080 ,为啥不从端口上进行区分同一个 Cookie 属性从而避免干扰呢?

这个在 Cookie 规范(
RFC 6265
)中有所描述,那就是 Cookie 不提供通过端口进行隔离的,也就是说如果一个 Cookie 可以被一台服务器上的运行在某一个端口上的一个服务所读取,那么也可以被这台服务器上运行在另外一个端口上的服务所读取;如果一个 Cookie 可以被一台服务器上的运行在某一个端口上的一个服务所写入,那么也可以被这台服务器上运行在另外一个端口上的服务所写入。

七、总结

本文从实际开发过程中遇到的循环登录问题入手,分析了由于设置 Secure 属性导致使用 HTTP 访问网页无法保存 Cookie 信息从而导致循环登录的根本原因,也给出了其他两个解决此种问题的方案,对于其他开发人员解决权限系统循环登录问题具有一定的借鉴意义。

参考资料:

单点登录实现思路及方案
Sessions don't work in Chrome but do in IE
8.5. Weak Confidentiality