11. 插件开发

这是一组文档,详细解释了什么是插件、它们是如何设计的以及它们如何在Tigase服务器中工作。文档的最后一部分逐步解释了如何为新插件创建代码。

11.1. 编写插件代码

节处理分4个步骤进行。不同类型的插件负责处理的每个步骤:

  1. XMPPPreprocessorIfc - 是数据包预处理插件的接口。

  2. XMPPProcessorIfc - 是数据包处理插件的接口。

  3. XMPPPostprocessorIfc - 是数据包后处理插件的接口。

  4. XMPPPacketFilterIfc - 是处理结果过滤的接口。

如果您查看这些接口中的任何一个,您只会发现一个方法。这是所有数据包处理发生的地方。它们都采用一组相似的参数,下面是对它们的描述:

  • 数据包数据包 - 正在处理的数据包。此参数可能永远不会为空。即使这不是一个不可变的对象,也不能改变它。在处理过程中,它的任何字段或属性都不能更改。

  • XMPPResourceConnection session - 用户会话,它保留所有用户会话数据并提供对用户数据存储库的访问权限。它允许仅在会话期间将信息存储在永久存储器或内存中。如果在处理数据包时没有在线用户会话,则该参数可以为空。

  • NonAuthUserRepository repo - 这是一个用户数据存储,通常在用户会话(上述参数)为空时使用。此存储库仅允许非常受限的访问。它允许存储一些用户私有数据(但不允许覆盖现有数据),例如离线用户的消息,它还允许读取用户公共数据,例如VCards。

  • Queue<Packet> results - 这是一个包含作为输入数据包处理结果生成的数据包的集合。无论发送对用户请求的响应还是将数据包转发到其目的地,始终需要创建输入数据包的副本并将其存储在 results 队列中。

  • Map<String, Object> settings - 此映射保留从Tigase服务器配置加载的插件特定设置。在大多数情况下,它是未使用的,但是如果插件需要访问外部数据库,这是将数据库连接字符串传递给插件的一种方式。

仔细查看一些接口后,您会发现它们扩展了另一个接口:XMPPImplIfc 提供有关插件实现的基本元信息。有关所有详细信息,请参阅 JavaDoc 文档。

出于本指南的目的,我们正在实现一个简单的插件,用于处理所有将数据包转发到目标地址的 <message/> 数据包。传入的数据包被转发到用户连接,而传出的数据包被转发到外部目标地址。这个 message plugin 实际上已经实现并且它可以在我们的Git存储库。代码里面已经有一些注释,但本指南更深入地介绍了实现细节。

首先,您必须选择要实现的插件类型。如果这将是一个数据包处理器,您必须实现 XMPPProcessorIfc 接口,如果这将是一个预处理器,那么您必须实现 XMPPPreprocessorIfc 接口。当然,您的实现可以实现多个接口,甚至是所有接口。还有两个抽象帮助类,您应该将其中一个用作所有插件的基础 XMPPProcessor 或使用 AnnotatedXMPPProcessor 来支持注释。

11.1.1. 使用注释支持

类声明应如下所示(假设您只实现数据包处理器):

public class Message extends AnnotatedXMPPProcessor
   implements XMPPProcessorIfc

首先要创建插件 ID。这是您放入配置文件中的唯一字符串,用于告诉服务器加载和使用插件。在大多数情况下,如果插件想要包含具有非常特定名称空间的元素的数据包,您可以使用XMLNS。当然,不能保证这个特定的XML元素也没有其他数据包。由于我们想处理所有消息并且不想花一整天时间考虑一个很酷的ID,假设我们的ID是:message

插件使用放置在类上的静态 ID 字段和 @Id 注释来通知它的存在:

@Id(ID)
public class Message extends AnnotatedXMPPProcessor
   implements XMPPProcessorIfc {
  protected static final String ID = "message";
}

如前所述,这个插件只接收它感兴趣的数据包进行处理。在这个例子中,插件只对带有 <message/> 元素的数据包感兴趣,并且只有它们在”jabber:client”命名空间。为了表明所有支持的元素和命名空间,我们必须再添加2个注释:

@Id(ID)
@Handles({
  @Handle(path={ "message" },xmlns="jabber:client")
})
public class Message extends AnnotatedXMPPProcessor
   implements XMPPProcessorIfc {
  private static final String ID = "message";
}

11.1.2. 使用较旧的非基于注释的实现

类声明应如下所示(假设您只实现数据包处理器):

public class Message extends XMPPProcessor
   implements XMPPProcessorIfc

首先要创建的是插件 ID ,如上所示。

插件使用以下代码通知它的ID:

private static final String ID = "message";
public String id() { return ID; }

如前所述,这个插件只接收它感兴趣的这种数据包进行处理。在这个例子中,插件只对带有 <message/> 元素的数据包感兴趣,并且只有它们在 “jabber :client” 命名空间。为了表明所有支持的元素和命名空间,我们必须再添加2个方法:

public String[] supElements() {
  return new String[] {"message"};
}

public String[] supNamespaces() {
  return new String[] {"jabber:client"};
}

11.1.3. 处理方法的实现

现在我们已经准备好在Tigase中加载的插件。下一步是实际的数据包处理方法。完整代码请参考Git中的插件。我只会在此处对可能令人困惑的元素进行评论或添加更多代码,这可能对您的情况有所帮助。

@Override
public void process(Packet packet, XMPPResourceConnection session,
    NonAuthUserRepository repo, Queue<Packet> results, Map<String, Object> settings)
    throws XMPPException {

    // For performance reasons it is better to do the check
    // before calling logging method.
    if (log.isLoggable(Level.FINEST)) {
        log.log(Level.FINEST, "Processing packet: {0}", packet);
    }

    // You may want to skip processing completely if the user is offline.
    if (session == null) {
        return;
    }    // end of if (session == null)

    try {

        // Remember to cut the resource part off before comparing JIDs
        BareJID id = (packet.getStanzaTo() != null) ? packet.getStanzaTo().getBareJID() : null;

        // Checking if this is a packet TO the owner of the session
        if (session.isUserId(id)) {

            // Yes this is message to 'this' client
            Packet result = packet.copyElementOnly();

            // This is where and how we set the address of the component
            // which should receive the result packet for the final delivery
            // to the end-user. In most cases this is a c2s or Bosh component
            // which keep the user connection.
            result.setPacketTo(session.getConnectionId(packet.getStanzaTo()));

            // In most cases this might be skipped, however if there is a
            // problem during packet delivery an error might be sent back
            result.setPacketFrom(packet.getTo());

            // Don't forget to add the packet to the results queue or it
            // will be lost.
            results.offer(result);

            return;
        }    // end of else

        // Remember to cut the resource part off before comparing JIDs
        id = (packet.getStanzaFrom() != null) ? packet.getStanzaFrom().getBareJID() : null;

        // Checking if this is maybe packet FROM the client
        if (session.isUserId(id)) {

            // This is a packet FROM this client, the simplest action is
            // to forward it to its destination:
            // Simple clone the XML element and....
            // ... putting it to results queue is enough
            results.offer(packet.copyElementOnly());

            return;
        }

        // Can we really reach this place here?
        // Yes, some packets don't even have from or to address.
        // The best example is IQ packet which is usually a request to
        // the server for some data. Such packets may not have any addresses
        // And they usually require more complex processing
        // This is how you check whether this is a packet FROM the user
        // who is owner of the session:
        JID jid = packet.getFrom();

        // This test is in most cases equal to checking getStanzaFrom()
        if (session.getConnectionId().equals(jid)) {

            // Do some packet specific processing here, but we are dealing
            // with messages here which normally need just forwarding
            Element el_result = packet.getElement().clone();

            // If we are here it means FROM address was missing from the
            // packet, it is a place to set it here:
            el_result.setAttribute("from", session.getJID().toString());

            Packet result = Packet.packetInstance(el_result, session.getJID(),
                packet.getStanzaTo());

            // ... putting it to results queue is enough
            results.offer(result);
        }
    } catch (NotAuthorizedException e) {
        log.warning("NotAuthorizedException for packet: " + packet);
        results.offer(Authorization.NOT_AUTHORIZED.getResponseMessage(packet,
                "You must authorize session first.", true));
    }    // end of try-catch
}

11.2. 插件配置

插件配置很简单。

通过 config.tdsl 文件告诉Tigase服务器加载或不加载插件。插件属于 'sess-man' 容器。要激活插件,只需将其列在 sess-man插件中即可。

如果您不想使用此方法来找出正在运行的插件,有两种方法可以识别插件是否正在运行。一是日志文件:logs/tigase-console.log。如果您查看内部,您会发现以下输出:

Loading plugin: jabber:iq:register ...
Loading plugin: jabber:iq:auth ...
Loading plugin: urn:ietf:params:xml:ns:xmpp-sasl ...
Loading plugin: urn:ietf:params:xml:ns:xmpp-bind ...
Loading plugin: urn:ietf:params:xml:ns:xmpp-session ...
Loading plugin: roster-presence ...
Loading plugin: jabber:iq:privacy ...
Loading plugin: jabber:iq:version ...
Loading plugin: http://jabber.org/protocol/stats ...
Loading plugin: starttls ...
Loading plugin: vcard-temp ...
Loading plugin: http://jabber.org/protocol/commands ...
Loading plugin: jabber:iq:private ...
Loading plugin: urn:xmpp:ping ...

这是在您的安装中加载的插件列表。

另一种方法是查看具有硬编码默认列表的会话管理器源代码:

private static final String[] PLUGINS_FULL_PROP_VAL =
  {"jabber:iq:register", "jabber:iq:auth", "urn:ietf:params:xml:ns:xmpp-sasl",
   "urn:ietf:params:xml:ns:xmpp-bind", "urn:ietf:params:xml:ns:xmpp-session",
   "roster-presence", "jabber:iq:privacy", "jabber:iq:version",
   "http://jabber.org/protocol/stats", "starttls", "msgoffline",
   "vcard-temp", "http://jabber.org/protocol/commands", "jabber:iq:private",
   "urn:xmpp:ping", "basic-filter", "domain-filter"};

如果您希望在这些默认值之外加载插件,您必须编辑列表并将您的插件ID作为值添加到’sess-man’下的插件列表中。假设我们的插件ID是 message 其在我们所有的示例中:

'sess-man' () {
    'jabber:iq:register' () {}
    'jabber:iq:auth' () {}
    message () {}
}

假设您的插件类位于类路径中,它将在运行时加载和使用。您可以通过在插件的括号内添加 class: class.implementing.plugin 来指定类。

备注

如果您的插件名称有任何特殊字符(-,:|/.),则需要将其封装在单引号中。

不过,插件配置还有另一部分。如果您查看了 编写插件代码 指南,您可以记住 Map settings 处理参数。这是您可以在配置文件中设置的属性映射,这些设置将在处理时传递给插件。

config.tdsl 就是放置这些东西的地方。这些属性从您的 plugin ID 开始,每个键和值都是下面的子项:

'sess-man' () {
    pluginID {
      key1 = 'val1'
      key2 = 'val2'
      key3 = 'val3'
    }
}

备注

从v8.0.0开始,您将不再能够为多个键指定一个值,您必须单独设置每个键。

最后但并非最不重要 - 如果您有 省略插件ID

'sess-man' () {
    key1 = 'val1'
}

那么配置的键值对将是一个全局/通用插件设置,可用于所有加载的插件。

11.3. SM和插件如何处理数据包

对于Tigase服务器插件开发,了解它是如何工作的很重要。有不同类型的插件负责在数据流的不同阶段处理数据包。在进行实际编码部分之前,请阅读下面的介绍。

11.3.1. 介绍

在Tigase服务器中,插件 是负责处理特定XMPP节的代码片段。一个单独的插件可能负责处理消息,另一个插件负责处理存在,一个单独的插件负责iq roster,另一个插件负责iq版本等等。

插件提供了有关它感兴趣的带有xmlns的确切XML元素名称的信息。因此,您可以创建一个插件,该插件对包含caps子项的所有数据包感兴趣。

特定节元素可能没有插件,在这种情况下,使用默认操作,即简单地将节转发到目标地址。一个特定的XML元素可能还有多个插件,然后它们都在不同的线程中同时处理相同的节,因此不能保证不同插件处理节的顺序。

每个节都通过会话管理器组件,该组件通过几个步骤处理数据包。看看下面的图片:

Consumer

图片显示会话管理器分4步处理每个节:

  1. 预处理 - 所有加载的预处理器接收数据包进行处理。它们在会话管理器线程中工作,并且没有用于处理的内部队列。由于它们在会话管理器线程中工作,因此将处理时间限制在绝对最短非常重要,因为它们可能会影响会话管理器的性能。预处理器的目的是将它们用于数据包阻塞。如果预处理结果为’true’,则数据包被阻塞并且不执行进一步处理。

  2. 处理 - 如果数据包没有被任何预处理器阻止,这是数据包通过的下一步。它被插入到所有对该特定XML元素感兴趣的处理器队列中。每个处理器都在一个单独的线程中工作,并有自己的内部固定大小的处理队列。

  3. 后处理 - 如果该节没有处理器,则数据包将通过所有后处理器。会话管理器后处理器中内置的最后一个后处理器尝试对步骤2中未处理的数据包应用默认操作。通常,默认操作只是将数据包转发到目的地。最常见的是应用于 <message/>数据包。

  4. 最后,如果以上3个步骤中的任何一个产生了输出/结果数据包,它们都会通过所有可能会或可能不会阻止它们的过滤器。

需要注意的重要一点是,我们有两种或两个地方可能会阻止或过滤数据包。一个地方是在插件处理数据包之前,另一个地方是在处理之后对处理器插件生成的所有结果应用过滤。

同样重要的是要注意会话管理器和处理器插件充当数据包消费者。数据包被用于处理,一旦处理完成,数据包就会被销毁。因此,要将数据包转发到目的地,处理器必须创建数据包的副本,设置所有性能和属性并将其作为处理结果返回。当然,处理器可以生成任意数量的数据包作为结果。可以在上述4个处理步骤中的任何一个中生成结果包。看看下面的图片:

User Send to Comp

如果数据包P1是从服务器外部发送的,例如发送到另一台服务器上的用户或某个组件(MUC、PubSub、传输),则其中一个处理器必须创建数据包的副本 (P2) 并正确设置所有属性和目标地址。会话管理器在处理期间已使用数据包P1,并且其中一个插件已生成新数据包。

在从组件返回给用户的过程中当然也会发生同样的情况:

Comp Sends to User

来自组件的数据包被处理,其中一个插件必须生成数据包的副本以将其交付给用户。当然,数据包转发是一个默认操作,其在特定数据包没有插件时被应用。

之所以这样实现,是因为输入数据包P1可以被许多插件同时处理,因此数据包实际上应该是不可变的,并且一旦到达会话管理器进行处理就不能更改。

最明显的处理工作流程是当用户向服务器发送请求并期望服务器响应时:

User Request Response

不过,这种设计有一个令人惊讶的结果。如果您查看显示2个用户之间通信的下图,您可以看到数据包在传送到最终目的地之前被复制了两次:

User Sends to User

数据包必须由会话管理器处理两次。第一次代表用户A处理它作为传出数据包,第二次代表用户B处理它作为传入数据包。

这是为了确保用户A有权发送数据包并且对数据包应用所有处理,同时确保用户B有权接收数据包并且应用所有处理。例如,如果用户B离线,则有一个离线消息处理器应该将数据包发送到数据库而不是用户B。

11.4. SASL自定义机制和配置

此API可从Tigase XMPP服务器版本5.2.0或我们当前主分支上的更高版本获得。

在8.0.0版中,API和自定义SASL机制的配置发生了重大变化。

请注意,API正在积极开发中。此说明可能随时更新。

11.4.1. 基本SASL配置

Tigase XMPP Server中的SASL实现与Java API兼容,使用完全相同的接口。

SASL实现由以下部分组成:

  1. 机制

  2. 回调处理程序

机制配置

要添加新机制,必须实现并注册该机制的新工厂。

添加注册新工厂的最简单方法是使用 @Bean 注释对其类进行注释:

使用工厂的注解设置id将SASL机制工厂注册到 customSaslFactory 的示例。

@Bean(name="customSaslFactory", parent = TigaseSaslProvider.class, active = true)
public class OwnFactory implements SaslServerFactory {}

也可以通过在 config.tdsl 文件中直接为bean ``customSaslFactory``指定类来完成,如下例所示:

注册SASL机制工厂的示例,其工厂的TDSL设置id为 customSaslFactory

'sess-man' () {
    'sasl-provider' () {
        customSaslFactory(class: com.example.OwnFactory) {}
    }
}

该类必须实现 SaslServerFactory 接口并具有不带任何参数的公共构造函数。 getMechanismNames() 方法返回的所有机制都将自动注册。

默认情况下可用和注册的默认工厂是 tigase.auth.TigaseSaslServerFactory ,它提供 PLAIN, ANONYMOUS, EXTERNAL, SCRAM-SHA-1, SCRAM-SHA-256SCRAM-SHA-512 机制。

CallbackHandler配置

CallbackHandler 是一个帮助类,用于从数据存储库加载/检索身份验证数据并将它们提供给机制。

要注册一个新的回调处理程序,您需要创建一个扩展 tigase.auth.CallbackHandlerFactory 的新类(如果您希望保留现有的SASL回调处理程序)或实现 tigase.auth.CallbackHandlerFactoryIfc。您将需要重写 create() 方法以在适当的时候返回您的自定义 CallbackHandler 的实例。

接下来,您需要注册 CallbackHandlerFactoryIfc 的新实现。 config.tdsl 文件应包括:

'sess-man' () {
    'sasl-provider' () {
        callback-handler-factory(class: com.example.OwnCallbackHandlerFactory) {}
    }
}

在身份验证过程中,Tigase服务器始终检查请求回调处理程序工厂以获取特定处理程序到选定机制,如果没有特定处理程序,则使用默认处理程序。

选择流中可用的机制

tigase.auth.MechanismSelector 接口用于选择流中可用的机制。方法 filterMechanisms() 应该返回一个具有可用机制的集合,基于:

  1. 所有注册的SASL工厂

  2. XMPP会话数据(来自 XMPPResourceConnection 类)

默认选择器从在 sasl-provider (TigaseSaslProvider) 中注册的所有机制工厂返回机制。

可以通过在 config.tdsl 文件中指定它的类来使用自定义选择器:

'sess-man' () {
    'sasl-provider' () {
        'mechanism-selector'(class: com.example.OwnSelector) {}
    }
}

11.4.2. 记录/认证

在客户端打开XMPP流后,服务器会检查哪些SASL机制可用于XMPP会话。根据流是否加密,根据域,服务器可以提供不同的可用身份验证机制。 MechanismSelector 负责选择机制。允许的机制列表存储在XMPP会话对象中。

当客户端/用户开始认证过程时,它使用一种特定的机制。它必须使用服务器提供的可用于此会话的机制之一。服务器检查客户端使用的机制是否在允许的机制列表中。如果检查成功,服务器将创建 SaslServer 类实例并继续交换身份验证信息。认证数据因使用的机制而异。

当SASL认证完成且没有任何错误时,Tigase服务器应该有授权的用户名或授权的BareJID。在第一种情况下,服务器会根据 to 属性中的流打开元素中使用的域自动构建用户的JID。

如果在成功验证后,方法调用:getNegotiatedProperty("IS_ANONYMOUS") 返回 Boolean.TRUE 则用户会话被标记为匿名。对于有效和注册用户,这可用于我们不想加载任何用户数据(例如名册、vcard、隐私列表等)的情况。这是对性能和资源使用的影响,可用于支持聊天等用例。授权是基于客户端数据库执行的,但我们不需要为用户会话加载任何XMPP特定数据。

更多关于实现的细节可以在 自定义机制开发 部分中找到。

11.4.3. 自定义机制开发

机制

来自 SaslServer 类的 getAuthorizationID() 方法 应该 返回裸JID授权用户。如果该方法仅返回用户名,例如 romeo,则服务器会自动附加域名以生成有效的BareJID:romeo@example.com。如果该方法返回完整、有效的 BareJID,则服务器不会更改任何内容。

SessionManagerHandlerhandleLogin() 方法将使用 getAuthorizationID() 提供的用户裸JID(或稍后使用流域名创建)来调用。

回调处理程序

对于每个会话授权,服务器都会创建一个新的单独的空处理程序。创建处理程序实例的工厂允许向处理程序注入不同的对象,具体取决于处理程序类实现的接口:

  • AuthRepositoryAware - 注入 AuthRepository;

  • DomainAware - 注入用户尝试进行身份验证的域名

  • NonAuthUserRepositoryAware - 注入 NonAuthUserRepository

通用说明

JabberIqAuth 用于非SASL身份验证机制使用与SASL机制相同的回调。

Repository 接口中的 auth 方法将被弃用。这些接口将仅被视为用户详细信息提供者。将有新的可用方法允许对数据库进行额外的登录操作,比如上次成功登录记录。