持久化

您可以使用 dsl 库来定义您的映射和应用程序的基本持久层。

有关更全面的示例,请查看存储库中的 examples 目录。

文档

如果您想围绕您的文档创建一个模型化的包装器,请使用 Document 类。它还可以用于在 Elasticsearch 中创建所有必要的映射和设置(有关详细信息,请参阅 文档生命周期)。

from datetime import datetime
from elasticsearch_dsl import Document, Date, Nested, Boolean, \
    analyzer, InnerDoc, Completion, Keyword, Text

html_strip = analyzer('html_strip',
    tokenizer="standard",
    filter=["standard", "lowercase", "stop", "snowball"],
    char_filter=["html_strip"]
)

class Comment(InnerDoc):
    author = Text(fields={'raw': Keyword()})
    content = Text(analyzer='snowball')
    created_at = Date()

    def age(self):
        return datetime.now() - self.created_at

class Post(Document):
    title = Text()
    title_suggest = Completion()
    created_at = Date()
    published = Boolean()
    category = Text(
        analyzer=html_strip,
        fields={'raw': Keyword()}
    )

    comments = Nested(Comment)

    class Index:
        name = 'blog'

    def add_comment(self, author, content):
        self.comments.append(
          Comment(author=author, content=content, created_at=datetime.now()))

    def save(self, ** kwargs):
        self.created_at = datetime.now()
        return super().save(** kwargs)

数据类型

Document 实例使用本机 Python 类型,如 strdatetime。在 ObjectNested 字段的情况下,将使用 InnerDoc 子类的实例,如上面示例中的 add_comment 方法,我们在这里创建了 Comment 类的实例。

有一些特定类型是作为此库的一部分创建的,以使使用某些字段类型更容易,例如在任何 范围字段 中使用的 Range 对象。

from elasticsearch_dsl import Document, DateRange, Keyword, Range

class RoomBooking(Document):
    room = Keyword()
    dates = DateRange()


rb = RoomBooking(
  room='Conference Room II',
  dates=Range(
    gte=datetime(2018, 11, 17, 9, 0, 0),
    lt=datetime(2018, 11, 17, 10, 0, 0)
  )
)

# Range supports the in operator correctly:
datetime(2018, 11, 17, 9, 30, 0) in rb.dates # True

# you can also get the limits and whether they are inclusive or exclusive:
rb.dates.lower # datetime(2018, 11, 17, 9, 0, 0), True
rb.dates.upper # datetime(2018, 11, 17, 10, 0, 0), False

# empty range is unbounded
Range().lower # None, False

Python 类型提示

如果需要,可以使用标准 Python 类型提示来定义文档字段。以下是一些简单的示例

from typing import Optional

class Post(Document):
    title: str                      # same as title = Text(required=True)
    created_at: Optional[datetime]  # same as created_at = Date(required=False)
    published: bool                 # same as published = Boolean(required=True)

重要的是要注意,当使用 Field 子类(如 TextDateBoolean)时,必须将它们放在赋值的右侧,如上面的示例所示。将这些类用作类型提示会导致错误。

Python 类型根据以下表格映射到其相应的字段类型

Python 类型到 DSL 字段映射

Python 类型

DSL 字段

str

Text(required=True)

bool

Boolean(required=True)

int

Integer(required=True)

float

Float(required=True)

bytes

Binary(required=True)

datetime

Date(required=True)

date

Date(format="yyyy-MM-dd", required=True)

要将字段类型为可选,可以使用 Python typing 包中的标准 Optional 修饰符。可以将 List 修饰符添加到字段以将其转换为数组,类似于在字段对象上使用 multi=True 参数。

from typing import Optional, List

class MyDoc(Document):
    pub_date: Optional[datetime]  # same as pub_date = Date()
    authors: List[str]            # same as authors = Text(multi=True, required=True)
    comments: Optional[List[str]] # same as comments = Text(multi=True)

还可以为字段提供 InnerDoc 子类的类型提示,在这种情况下,它将成为该类的 Object 字段。当 InnerDoc 子类用 List 包装时,将创建一个 Nested 字段。

from typing import List

class Address(InnerDoc):
    ...

class Comment(InnerDoc):
    ...

class Post(Document):
    address: Address         # same as address = Object(Address, required=True)
    comments: List[Comment]  # same as comments = Nested(Comment, required=True)

不幸的是,不可能有 Python 类型提示来唯一标识每种可能的 Elasticsearch 字段类型。要选择与表格中不同的字段类型,可以在字段声明中将字段实例显式添加为右侧赋值。下一个示例创建一个类型为 Optional[str] 的字段,但映射到 Keyword 而不是 Text

class MyDocument(Document):
    category: Optional[str] = Keyword()

此形式也可以在需要提供其他选项来初始化字段时使用,例如在使用自定义分析器设置或更改 required 默认值时

class Comment(InnerDoc):
    content: str = Text(analyzer='snowball', required=True)

当使用上面的类型提示时,DocumentInnerDoc 的子类继承了与 Python 数据类相关联的一些行为,如 PEP 681dataclass_transform 装饰器 所定义。要添加每个字段的数据类选项(如 defaultdefault_factory),可以在类型化字段声明的右侧使用 mapped_field() 包装器

class MyDocument(Document):
    title: str = mapped_field(default="no title")
    created_at: datetime = mapped_field(default_factory=datetime.now)
    published: bool = mapped_field(default=False)
    category: str = mapped_field(Keyword(required=True), default="general")

当使用 mapped_field() 包装函数时,可以将显式字段类型实例作为第一个位置参数传递,如上面示例中的 category 字段。

静态类型检查器(如 mypypyright)可以使用类型提示和添加到 mapped_field() 函数中的数据类特定选项来改进类型推断,并在 IDE 中提供更好的实时建议。

类型检查器无法推断出正确类型的一种情况是在使用字段作为类属性时。考虑以下示例

class MyDocument(Document):
    title: str

doc = MyDocument()
# doc.title is typed as "str" (correct)
# MyDocument.title is also typed as "str" (incorrect)

为了帮助类型检查器正确识别类属性,必须将 M 泛型用作类型提示的包装器,如以下示例所示

from elasticsearch_dsl import M

class MyDocument(Document):
    title: M[str]
    created_at: M[datetime] = mapped_field(default_factory=datetime.now)

doc = MyDocument()
# doc.title is typed as "str"
# doc.created_at is typed as "datetime"
# MyDocument.title is typed as "InstrumentedField"
# MyDocument.created_at is typed as "InstrumentedField"

请注意,M 类型提示不提供任何运行时行为,并且不需要使用它,但它可能有助于消除 IDE 或类型检查构建中的虚假类型错误。

当字段作为类属性访问时返回的 InstrumentedField 对象是字段实例的代理,可以在需要引用字段的任何地方使用,例如在 Search 对象中指定排序选项时

# sort by creation date descending, and title ascending
s = MyDocument.search().sort(-MyDocument.created_at, MyDocument.title)

在指定排序顺序时,可以在类字段属性上使用 +- 一元运算符来指示升序和降序。

关于日期的说明

elasticsearch-dsl 将始终尊重传递到 Elasticsearch 中或存储在 Elasticsearch 中的 datetime 对象上的时区信息(或缺乏时区信息)。Elasticsearch 本身将所有没有时区信息的日期时间解释为 UTC。如果您希望在 Python 代码中反映这一点,可以在实例化 Date 字段时指定 default_timezone

class Post(Document):
    created_at = Date(default_timezone='UTC')

在这种情况下,传递的任何 datetime 对象(或从 Elasticsearch 解析的任何 datetime 对象)将被视为在 UTC 时区中。

文档生命周期

在您首次使用 Post 文档类型之前,您需要在 Elasticsearch 中创建映射。为此,您可以使用 Index 对象,也可以通过调用 init 类方法来直接创建映射

# create the mappings in Elasticsearch
Post.init()

此代码通常在代码部署期间在应用程序的设置中运行,类似于运行数据库迁移。

要创建一个新的 Post 文档,只需实例化该类并传入您希望设置的任何字段,然后您可以使用标准属性设置来更改/添加更多字段。请注意,您不限于显式定义的字段

# instantiate the document
first = Post(title='My First Blog Post, yay!', published=True)
# assign some field values, can be values or lists of values
first.category = ['everything', 'nothing']
# every document has an id in meta
first.meta.id = 47


# save the document into the cluster
first.save()

所有元数据字段(idroutingindex 等)可以通过 meta 属性或直接使用带下划线的变体来访问(和设置)

post = Post(meta={'id': 42})

# prints 42
print(post.meta.id)

# override default index
post.meta.index = 'my-blog'

注意

通过 meta 访问所有元数据意味着此名称是保留的,并且您不应该在文档上有一个名为 meta 的字段。但是,如果您需要它,您仍然可以使用获取项目(而不是属性)语法来访问数据:post['meta']

要检索现有文档,请使用 get 类方法

# retrieve the document
first = Post.get(id=42)
# now we can call methods, change fields, ...
first.add_comment('me', 'This is nice!')
# and save the changes into the cluster again
first.save()

还可以通过 update 方法使用 更新 API。默认情况下,除了 API 的参数之外的任何关键字参数都将被视为具有新值的字段。这些字段将在文档的本地副本上更新,然后作为部分文档发送以进行更新

# retrieve the document
first = Post.get(id=42)
# you can update just individual fields which will call the update API
# and also update the document in place
first.update(published=True, published_by='me')

如果您希望使用 painless 脚本执行更新,您可以将脚本字符串作为 script 传递,或者将 存储脚本id 通过 script_id 传递。然后,update 方法的所有其他关键字参数将作为脚本的参数传递。文档不会就地更新。

# retrieve the document
first = Post.get(id=42)
# we execute a script in elasticsearch with additional kwargs being passed
# as params into the script
first.update(script='ctx._source.category.add(params.new_category)',
             new_category='testing')

如果在 Elasticsearch 中找不到文档,将引发异常(elasticsearch.NotFoundError)。如果你希望返回 None 而不是引发异常,只需传递 ignore=404 来抑制异常。

p = Post.get(id='not-in-es', ignore=404)
p is None

当你希望通过其 id 同时检索多个文档时,可以使用 mget 方法。

posts = Post.mget([42, 47, 256])

mget 默认情况下,如果任何文档未找到,将引发 NotFoundError,如果任何文档导致错误,将引发 RequestError。你可以通过设置参数来控制此行为。

raise_on_error

如果为 True(默认),则任何错误都将导致引发异常。否则,所有包含错误的文档将被视为缺失。

missing

可以有三个可能的值:'none'(默认)、'raise''skip'。如果文档缺失或出错,它将被替换为 None,引发异常或完全跳过输出列表中的文档。

Document 关联的索引可以通过 _index 类属性访问,该属性使你能够访问 Index 类。

_index 属性也是 load_mappings 方法的所在地,该方法将从 Elasticsearch 更新 Index 上的映射。如果你使用动态映射并希望类了解这些字段(例如,如果你希望 Date 字段被正确地(反)序列化),这非常有用。

Post._index.load_mappings()

要删除文档,只需调用其 delete 方法。

first = Post.get(id=42)
first.delete()

分析

要为 Text 字段指定 analyzer 值,你可以只使用分析器的名称(作为字符串),并依赖于已定义的分析器(如内置分析器)或手动定义分析器。

或者,你可以创建自己的分析器,并让持久层处理其创建,从我们之前的示例中。

from elasticsearch_dsl import analyzer, tokenizer

my_analyzer = analyzer('my_analyzer',
    tokenizer=tokenizer('trigram', 'nGram', min_gram=3, max_gram=3),
    filter=['lowercase']
)

每个分析对象都需要有一个名称(my_analyzertrigram 在我们的示例中),分词器、分词过滤器和字符过滤器也需要指定类型(nGram 在我们的示例中)。

一旦你拥有自定义 analyzer 的实例,你也可以通过使用 simulate 方法在它上面调用 analyze API

response = my_analyzer.simulate('Hello World!')

# ['hel', 'ell', 'llo', 'lo ', 'o w', ' wo', 'wor', 'orl', 'rld', 'ld!']
tokens = [t.token for t in response.tokens]

注意

在创建依赖于自定义分析器的映射时,索引必须不存在或已关闭。要创建多个 Document 定义的映射,可以使用 Index 对象。

class Meta 选项

在文档定义中的 Meta 类中,你可以为文档定义各种元数据。

mapping

Mapping 类的可选实例,用作从文档类本身的字段创建的映射的基础。

Meta 类上的任何属性,如果它们是 MetaField 的实例,将用于控制元字段的映射(_alldynamic 等)。只需将参数(不带前导下划线)命名为要映射的字段,并将任何参数传递给 MetaField 类。

class Post(Document):
    title = Text()

    class Meta:
        all = MetaField(enabled=False)
        dynamic = MetaField('strict')

class Index 选项

此部分的 Document 定义可以包含有关索引、其名称、设置和其他属性的任何信息。

name

要使用的索引的名称,如果它包含通配符(*),则它不能用于任何写入操作,并且必须在调用 .save() 等方法时显式传递 index 关键字参数。

using

要使用的默认连接别名,默认为 'default'

settings

包含 Index 对象的任何设置的字典,如 number_of_shards

analyzers

应该在索引上定义的分析器的附加列表(有关详细信息,请参阅 Analysis)。

aliases

包含任何别名定义的字典。

文档继承

你可以使用标准的 Python 继承来扩展模型,这在一些场景中非常有用。例如,如果你想有一个 BaseDocument 定义一些公共字段,这些字段应该被多个不同的 Document 类共享。

class User(InnerDoc):
    username = Text(fields={'keyword': Keyword()})
    email = Text()

class BaseDocument(Document):
    created_by = Object(User)
    created_date = Date()
    last_updated = Date()

    def save(**kwargs):
        if not self.created_date:
            self.created_date = datetime.now()
        self.last_updated = datetime.now()
        return super(BaseDocument, self).save(**kwargs)

class BlogPost(BaseDocument):
    class Index:
        name = 'blog'

另一个用例是使用 join 类型 在单个索引中拥有多个不同的实体。你可以看到 示例 的这种方法。请注意,在这种情况下,如果子类没有定义自己的 Index 类,则映射将合并并在所有子类之间共享。

Index

在典型的场景中,在 Document 类上使用 class Index 足以执行任何操作。但在一些情况下,直接操作 Index 对象可能很有用。

Index 是一个类,负责保存与 Elasticsearch 中索引相关的所有元数据 - 映射和设置。它在定义映射时最有用,因为它允许轻松地同时创建多个映射。这在迁移中设置 Elasticsearch 对象时特别有用。

from elasticsearch_dsl import Index, Document, Text, analyzer

blogs = Index('blogs')

# define custom settings
blogs.settings(
    number_of_shards=1,
    number_of_replicas=0
)

# define aliases
blogs.aliases(
    old_blogs={}
)

# register a document with the index
blogs.document(Post)

# can also be used as class decorator when defining the Document
@blogs.document
class Post(Document):
    title = Text()

# You can attach custom analyzers to the index

html_strip = analyzer('html_strip',
    tokenizer="standard",
    filter=["standard", "lowercase", "stop", "snowball"],
    char_filter=["html_strip"]
)

blogs.analyzer(html_strip)

# delete the index, ignore if it doesn't exist
blogs.delete(ignore=404)

# create the index in elasticsearch
blogs.create()

你还可以为索引设置模板,并使用 clone 方法创建特定副本。

blogs = Index('blogs', using='production')
blogs.settings(number_of_shards=2)
blogs.document(Post)

# create a copy of the index with different name
company_blogs = blogs.clone('company-blogs')

# create a different copy on different cluster
dev_blogs = blogs.clone('blogs', using='dev')
# and change its settings
dev_blogs.setting(number_of_shards=1)

IndexTemplate

elasticsearch-dsl 还提供了一个选项,可以使用 IndexTemplate 类管理 Elasticsearch 中的 索引模板,该类与 Index 具有非常相似的 API。

一旦索引模板保存在 Elasticsearch 中,其内容将自动应用于与模板模式匹配的新索引(现有索引完全不受模板影响)(在我们的示例中,任何以 blogs- 开头的索引),即使索引是在将文档索引到该索引时自动创建的。

由单个模板控制的一组基于时间的索引的潜在工作流程。

from datetime import datetime

from elasticsearch_dsl import Document, Date, Text


class Log(Document):
    content = Text()
    timestamp = Date()

    class Index:
        name = "logs-*"
        settings = {
          "number_of_shards": 2
        }

    def save(self, **kwargs):
        # assign now if no timestamp given
        if not self.timestamp:
            self.timestamp = datetime.now()

        # override the index to go to the proper timeslot
        kwargs['index'] = self.timestamp.strftime('logs-%Y%m%d')
        return super().save(**kwargs)

# once, as part of application setup, during deploy/migrations:
logs = Log._index.as_template('logs', order=0)
logs.save()

# to perform search across all logs:
search = Log.search()