Spring LDAP官方文档翻译(1-5章)

6- 章的翻译见:https://www.jianshu.com/p/835c2db4a1c4


说明

该文档根据官方英文文档翻译而来,该文档是对spring-ldap-core的2.3.2.RELEASE版本的介绍。翻译如下。


Spring LDAP 参考文档

Mattias Hellborg Arthursson,Ulrik Sandberg,Eric Dalquist,Keith Barlow,Rob Winch。2.3.2.RELEASE版本。
Spring LDAP使得我们构建基于Spring的那些使用了LDAP协议的应用变得简单
假如你不把本文档设为收费阅读,甚至你在提供本文内容时在文档中包含此版权说明,那你可以以纸质版或电子版的形式拷贝本文的副本来自己使用或做他用

前言

JDNI( Java Naming and Directory Interface)之于LDAP编程就像JDBC( Java Database Connectivity)之于SQL编程。在JDBC与JNDI/LDAP之间有些相似之处。尽管它们是两套有着各自优缺点的API,但它们都有一下一些不讨人喜欢的特点:

  • 它们都要求我们写大量业务无关的代码,即便我们只是要写一个很简单的功能
  • 无论程序运行情况,都要求我们正确的关闭资源
  • 对异常的处理不友好
    以上几点导致我们在使用这些API时出现大量重复的代码。我们都知道,重复代码是最糟糕的代码风格之一。总而言之,我们可以总结为:在JAVA程序中使用JDBC与LDAP编程会变得极度无聊与啰嗦。

注:个人感觉原文文档有笔误,按照前后文的意思,应该是使用JDBC与JNDI直接操作来编程会很无聊与啰嗦

Spring JDBC,Spring框架的核心组件,对SQL编程提供了非常实用的功能。同样地,我们需要一个类似的框架来简化JAVA中的LDAP编程。

1.引言

1.1 概述

设计Spring LDAP是为了简化JAVA中的LDAP操作。以下是该lib提供的功能:

  • JdbcTemplate:为LDAP编程设计了一个简化的模版
  • JPA/Hibernate:设计了基于注解的对象/目录映射
  • Spring Data repository的支持,包括对QueryDSL的支持
  • 简化构建LDAP查询与LDAP DN的功能
  • LDAP连接池
  • 客户端LDAP补偿事务的支持

1.2传统的JAVA LDAP操作 V/S 使用LdapTemplate

我们考虑这么一个方法:查询数据中所有的Person并将它们的name属性添加到list中,返回这个list。使用JDBC的话,我们要创建一个连接connection,并使用statement来创建一个查询,在返回的结果集result set中,我们循环每一条结果,并取出我们需要的那一列中的内容,将该内容放入list中。

类似的,我们使用JNDI来操作LDAP数据库的话,需要创建一个context,并使用查询过滤器search filter来进行查询。然后我们会循环返回的naming enumeration并取出我们想要的属性attribute,将该属性的内容放入list中。

传统的使用JAVA JNDI来实现上述查询方法的代码如下所示。注意粗体的代码,这些代码是真正与业务逻辑相关的代码,其他的都是附属的代码。

package com.example.repository;

public class TraditionalPersonRepoImpl implements PersonRepo {
   public List<String> getAllPersonNames() {
      Hashtable env = new Hashtable();
      env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
      env.put(Context.PROVIDER_URL, "ldap://localhost:389/dc=example,dc=com");

      DirContext ctx;
      try {
         ctx = new InitialDirContext(env);
      } catch (NamingException e) {
         throw new RuntimeException(e);
      }

      List<String> list = new LinkedList<String>();
      NamingEnumeration results = null;
      try {
         SearchControls controls = new SearchControls();
         controls.setSearchScope(SearchControls.SUBTREE_SCOPE);
         results = ctx.search("", "(objectclass=person)", controls);

         while (results.hasMore()) {
            SearchResult searchResult = (SearchResult) results.next();
            Attributes attributes = searchResult.getAttributes();
            Attribute attr = attributes.get("cn");
            String cn = attr.get().toString();
            list.add(cn);
         }
      } catch (NameNotFoundException e) {
         // The base context was not found.
         // Just clean up and exit.
      } catch (NamingException e) {
         throw new RuntimeException(e);
      } finally {
         if (results != null) {
            try {
               results.close();
            } catch (Exception e) {
               // Never mind this.
            }
         }
         if (ctx != null) {
            try {
               ctx.close();
            } catch (Exception e) {
               // Never mind this.
            }
         }
      }
      return list;
   }
}

注:使用markdown编辑不知道怎么在代码中加粗,求大佬告知,详细加粗部分可参考:https://docs.spring.io/spring-ldap/docs/current/reference/#introduction

通过使用Spring LDAP的AttributesMapper类与LdapTemplate类,我们可以编写下面的代码实现相同的功能:

package com.example.repo;
import static org.springframework.ldap.query.LdapQueryBuilder.query;

public class PersonRepoImpl implements PersonRepo {
   private LdapTemplate ldapTemplate;

   public void setLdapTemplate(LdapTemplate ldapTemplate) {
      this.ldapTemplate = ldapTemplate;
   }

   public List<String> getAllPersonNames() {
      return ldapTemplate.search(
         query().where("objectclass").is("person"),
         new AttributesMapper<String>() {
            public String mapFromAttributes(Attributes attrs)
               throws NamingException {
               return attrs.get("cn").get().toString();
            }
         });
   }
}

上述代码中公式化的代码明显比传统方法少了许多。LdapTemplate的search方法能确保创建DirContext实例,执行查询,并使用给定 的AttributesMapper来将属性attributes转换成字符串String,然后将字符串放入list中,最后返回这个list。它同时也能确保NamingEnumeration与DirContext能够正确的关闭,并且处理可能出现的异常。

不用说,这是Spring框架的子项目,我们可以使用Spring来配置我们的应用。

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:ldap="http://www.springframework.org/schema/ldap"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
       http://www.springframework.org/schema/ldap http://www.springframework.org/schema/ldap/spring-ldap.xsd">

   <ldap:context-source
          url="ldap://localhost:389"
          base="dc=example,dc=com"
          username="cn=Manager"
          password="secret" />

   <ldap:ldap-template id="ldapTemplate" />

   <bean id="personRepo" class="com.example.repo.PersonRepoImpl">
      <property name="ldapTemplate" ref="ldapTemplate" />
   </bean>
</beans>

为了能使用LDAP定制的XML命名空间namespace来配置Spring LDAP组件,你需要向上面的例子一样,将该命名空间namespace的引用加入到自己的XML声明中。

1.3 2.2版本的新特性

2.2版本更加详细完整的信息请参阅变更日志2.2.0.RC1。以下是Spring LDAP 2.2版本的亮点:

  • #415 - 对Spring5的支持
  • #399 - 内置UnboundID LDAP服务器
  • #410 - 增加对支持的Commons Pool 2的文档

1.4 2.1版本的新特性

2.1版本更加详细完整的信息请参阅变更日志2.1.0.RC12.1.0。以下是2.1版本的亮点:

  • #390 - Spring Data Hopper的支持
  • #351 - 支持commons-pool2
  • #370 - 支持在XML 命名空间中书写合适的占位符
  • #392 - 支持Document Testing 文档的测试
  • #401 - 转为使用 assertj
  • 从JIRA迁移到GitHub Issues
  • 增加 Gitter Chat

1.5 2.0版本的新特性

尽管2.0版本的Spring LDAP API有了很明显的改变,但我们仍然为向后兼容尽我们最大的努力。使用了Spring LDAP 1.3.X 版本的代码升级为2.0的lib后无需修改即可编译与运行,基本不会有异常产生。

为了几个重要的代码重构,我们将一小部分类移动到了新的包packages中,当代码中用到这些类的时候可能会报异常。被移动的类通常不会是公共的被人们调用的API,并且整个迁移过程非常顺利。如果升级后无论哪个Spring LDAP 的类找不到了,都可以在IDE开发工具中重新import导入。由于有许多改进后的API,你可能会遇到提醒你“过时”deprecation的警告,我们推荐不再使用过时的类与方法,并尽可能多的使用2.0版本提供的新的以及改进的API。
以下列表显示了在Spring LDAP 2.0 中的重要更改:

  • Spring LDAP要求至少JAVA 6版本。支持Spring 2及以上版本。
  • 核心API根据JAVA5+的特性(如泛型与可变参数)进行了升级。因此整个spring-ldap-tiger模块都变的过时了deprecated,我们建议使用者转而使用Spring LDAP的核心类。在现有代码中,参数化的核心接口会导致一些编译警告产生,使用这些API的用户要采取适当的方式来去掉这些警告。
  • ODM(Object-Directory Mapping)功能被移动到core包中。并且LdapOperations/LdapTemplate中的一些方法使用了ODM的功能,能够将属性自动转化为被ODM注解的类对象,或从ODM注解的类对象获取属性值。更详细的内容请参考Object-Directory Mapping (ODM)
  • 现在终于能够通过XML命名空间来简化配置Spring LDAP了。详细信息请参考Configuration
  • Spring LDAP 提供了对Spring Data Repository 和 QueryDSL的参考。详情参阅Spring LDAP Repositories
  • 在DirContextAdapter 与 ODM中,作为属性值的Name与DN(distinguished name)是等价的。详情参阅DirContextAdapter and Distinguished Names as Attribute Values.ODM and Distinguished Names as Attribute Values.
  • DistinguishedName 以及相关的类已经过时了,并且被JAVA的LdapName所取代。详情参阅Dynamically Building Distinguished Names,该文章说明了如何使用函数库中的LdapNames。
  • 新增了支持更加流畅的创建LDAP的查询query。这让我们能在Spring LDAP中使用LDAP查询时有了更好的编程体验。关于创建LDAP查询query的更多信息请参阅Building LDAP QueriesAdvanced LDAP Queries
  • LdapTemplate 的旧有的authenticate 已经过时。被LdapQuery 对象中的几个authenticate 新方法所取代,新方法能够在认证失败后抛出异常,这让用户在查找认证失败的原因时变得简单。
  • 使用2.0版本特性写的示例已经上传了。为了写这个LDAP 用户管理应用的示例我们付出了很大的努力。

1.6 包的概览

为了使用Spring LDAP你至少需要一下:

  • spring-ldap-core (Spring LDAP 函数库)
  • spring-core(框架内部使用的各种实用类)
  • spring-beans (包含操作JAVA beans的接口和类)
  • spring-data-commons(支持repository的基础内容等等)
  • slf4j (内部使用的简单的日志记录)
    除了上述必须的依赖外,下面这些依赖在使用一些特定功能时也是必须的:
  • spring-context (Spring Application Context能够为你的应用程序对象增加使用统一API获取资源的能力,如果你的应用程序是使用Spring Application Context你可能需要这个依赖。如果你打算使用BaseLdapPathBeanPostProcessor,你一定需要这个依赖)
  • spring-tx(如果你打算使用客户端补偿事务的支持的话,需要该依赖)
  • spring-jdbc(如果你打算使用客户端补偿事务的支持的话,需要该依赖)
  • commons-pool (如果你打算使用连接池的功能,需要该依赖)
  • spring-batch(如果你需要解析LDIF文件的功能,需要该依赖)

1.7 如何开始

这个示例提供了一些有用的示范,它们展示了一下常见的Spring LDAP的使用案例。

1.8 获得支持

社区论坛地址:
http://forum.spring.io/forum/spring-projects/data/ldap。项目官网:
http://spring.io/spring-ldap/

1.9 致谢

Spring LDAP项目初期由Jayway资助。目前项目的维护是由Pivotal资助。
感谢Structure101(用于项目架构)提供的开源许可证。这帮助我们有一个良好的项目结构。

2. 基本用法

2.1 在search 与 lookup中使用 AttributesMapper

在这个例子中,我们将使用AttributesMapper来轻松得到一个包含所有person对象的name属性值的list列表。
使用AttributesMapper 返回(获取)单一属性

package com.example.repo;
import static org.springframework.ldap.query.LdapQueryBuilder.query;

public class PersonRepoImpl implements PersonRepo {
   private LdapTemplate ldapTemplate;

   public void setLdapTemplate(LdapTemplate ldapTemplate) {
      this.ldapTemplate = ldapTemplate;
   }

   public List<String> getAllPersonNames() {
      return ldapTemplate.search(
         query().where("objectclass").is("person"),
         new AttributesMapper<String>() {
            public String mapFromAttributes(Attributes attrs)
               throws NamingException {
               return (String) attrs.get("cn").get();
            }
         });
   }
}

这个AttributesMapper 通过Attributes 来获取需要的属性值并返回这个值。本质上,LdapTemplate 会循环所有找到的条目entry,对每个条目都调用AttributesMapper 并将结果放入list中,最终这个list会作为search方法的返回值。
需要注意的是我们能够很容易的修改AttributesMapper 的实现(implementation)来获取整个person对象:
使用AttributesMapper 返回person对象

package com.example.repo;
import static org.springframework.ldap.query.LdapQueryBuilder.query;

public class PersonRepoImpl implements PersonRepo {
   private LdapTemplate ldapTemplate;
   ...
   private class PersonAttributesMapper implements AttributesMapper<Person> {
      public Person mapFromAttributes(Attributes attrs) throws NamingException {
         Person person = new Person();
         person.setFullName((String)attrs.get("cn").get());
         person.setLastName((String)attrs.get("sn").get());
         person.setDescription((String)attrs.get("description").get());
         return person;
      }
   }

   public List<Person> getAllPersons() {
      return ldapTemplate.search(query()
          .where("objectclass").is("person"), new PersonAttributesMapper());
   }
}

LDAP中的条目是根据DN来唯一标识。如果你有一个条目的DN值,那你可以通过该值来直接获取到该条目,这在java LDAP中被称为lookup。下面的例子展示了如何使用lookup获取person对象:
使用lookup获取person对象

package com.example.repo;

public class PersonRepoImpl implements PersonRepo {
   private LdapTemplate ldapTemplate;
   ...
   public Person findPerson(String dn) {
      return ldapTemplate.lookup(dn, new PersonAttributesMapper());
   }
}

上面的程序将会找到特定的DN然后将属性值传递给提供的AttributesMapper,该例中使用了PersonAttributesMapper,将会返回一个person对象。

2.2. 创建 LDAP Queries

LDAP查询search方法包括以下几项参数:

  • Base LDAP path -从LDAP树形结构哪个几点开始查询
  • Search scope -查询范围,说明了LDAP树形结构的查询深度
  • Attributes 返回的属性
  • Search filter -使用scope来查询时的查询条件

为了更好的创建LDAP 查询,Spring LDAP提供了LdapQueryBuilder类以及一些好用的API 方法。

假设我们想要执行一个查询,它的base DN为:dc=261consulting,dc=com,返回sn与cn属性值,查询条件为(&(objectclass=person)(sn=?)),我们希望查询条件中的?会被sn的值(lastName)所取代,我们可以使用LdapQueryBuilder完成上面的查询:
动态创建search filter查询条件

package com.example.repo;
import static org.springframework.ldap.query.LdapQueryBuilder.query;

public class PersonRepoImpl implements PersonRepo {
   private LdapTemplate ldapTemplate;
   ...
   public List<String> getPersonNamesByLastName(String lastName) {

      LdapQuery query = query()
         .base("dc=261consulting,dc=com")
         .attributes("cn", "sn")
         .where("objectclass").is("person")
         .and("sn").is(lastName);

      return ldapTemplate.search(query,
         new AttributesMapper<String>() {
            public String mapFromAttributes(Attributes attrs)
               throws NamingException {

               return (String) attrs.get("cn").get();
            }
         });
   }
}

除了简化创建一个复杂的查询外,LdapQueryBuilder 以及与之相关的类同时也提供了针对查询条件中的非安全字符的合适处理。这能够防止"LDAP注入",类比“sql注入”,“LDAP注入”是指用户使用一些字符想LDAP中执行一些我们不希望产生的操作。

在LdapTemplate 类中有许多重载的方法来执行LDAP查询操作。这是为了适应尽可能多的不同的用户情景与编程风格。在绝大多数的使用案例中,我们推荐使用将LdapQuery作为输入参数的方法 。

在执行search 与 lookup查询数据时,AttributesMapper 只是可用的回调接口中的其中一种。可以查阅 Simplifying Attribute Access and Manipulation with DirContextAdapter
使用其他可用的AttributesMapper 的替代类。

欲知更多的关于LdapQueryBuilder 的信息,请参阅Advanced LDAP Queries

2.3. 动态创建DN

DN的JAVA实现 --LdapName,能够在解析DN值时有不错的表现。但是在实际应用中,这个JAVA实现有如下一些缺点:

  • LdapName 的实现类是状态可变的(读者可以以java mutable为关键字搜索java可变状态对象的相关信息),这一特点不太适合展示对象的唯一标识。
  • 尽管它有状态可变的特性,但是使用LdapName来动态创建或修改DN值写起来也有些冗长。因此使用其来提取某个索引的值或者(尤其是)根据名称来取值会显得有点尴尬。
  • 许多LdapName的操作会抛出编译期异常(checked Exception),这要求我们使用try-catch来包裹程序,然而这些error是程序错误并且无法用有意义的方式进行处理。

为了简化针对DN的操作,Spring LDAP提供了LdapNameBuilder以及LdapUtils中的一系列相关方法,来操作并封装LdapName。

2.3.1. 示例

*使用LdapNameBuilder来动态创建一个LdapName *

package com.example.repo;
import org.springframework.ldap.support.LdapNameBuilder;
import javax.naming.Name;

public class PersonRepoImpl implements PersonRepo {
  public static final String BASE_DN = "dc=example,dc=com";

  protected Name buildDn(Person p) {
    return LdapNameBuilder.newInstance(BASE_DN)
      .add("c", p.getCountry())
      .add("ou", p.getCompany())
      .add("cn", p.getFullname())
      .build();
  }
  ...

假设Person对象有一下属性

Attribute Name Attribute Value
country Sweden
company Some Company
fullname Some Person

上面的代码将会返回如下的DN:

cn=Some Person, ou=Some Company, c=Sweden, dc=example, dc=com

使用LdapUtils根据DN来提取属性值

package com.example.repo;
import org.springframework.ldap.support.LdapNameBuilder;
import javax.naming.Name;
public class PersonRepoImpl implements PersonRepo {
...
protected Person buildPerson(Name dn, Attributes attrs) {
  Person person = new Person();
  person.setCountry(LdapUtils.getStringValue(dn, "c"));
  person.setCompany(LdapUtils.getStringValue(dn, "ou"));
  person.setFullname(LdapUtils.getStringValue(dn, "cn"));
  // Populate rest of person object using attributes.

  return person;
}

由于1.4一下的java版本没有提供DN的实现类,Spring LDAP 1.x提供了它自己的实现类 DistinguishedName。这个实现自身有些缺点并且在2.0版本中被标为了过时deprecated方法。现在推荐用户使用上面所示例的LdapName的封装的一些工具类如LdapUtils来替代DistinguishedName。

2.4. 绑定与解绑(增加与删除)

2.4.1. 增加数据

在Java LDAP中,增加数据被称为绑定。这有时候会造成困惑,因为在LDAP的术语中绑定一词有完全不同的意义。JNDI中的绑定就是LDAP中的增加数据,将一个含有一些列属性值的并有着唯一DN值的条目entry插入到ldap中。下面的示例中展示了如何使用LdapTemplate来插入数据:
使用Attributes来插入数据

package com.example.repo;

public class PersonRepoImpl implements PersonRepo {
   private LdapTemplate ldapTemplate;
   ...
   public void create(Person p) {
      Name dn = buildDn(p);
      ldapTemplate.bind(dn, null, buildAttributes(p));
   }

   private Attributes buildAttributes(Person p) {
      Attributes attrs = new BasicAttributes();
      BasicAttribute ocattr = new BasicAttribute("objectclass");
      ocattr.add("top");
      ocattr.add("person");
      attrs.put(ocattr);
      attrs.put("cn", "Some Person");
      attrs.put("sn", "Person");
      return attrs;
   }
}

这样手动的添加属性虽然很繁琐并且冗长,但是能够很好的满足我们的要求。当然也能够更加简化该增加数据的操作,请参阅:Simplifying Attribute Access and Manipulation with DirContextAdapter

2.4.2 删除数据

在Java LDAP中删除数据被称为解绑(unbinding)。JNDI中的解绑就是LDAP中的删除操作,该操作会根据DN值把特定的条目从LDAP树种删除。下面的示例展示了如何使用LdapTemplate来删除数据:
删除数据

package com.example.repo;

public class PersonRepoImpl implements PersonRepo {
   private LdapTemplate ldapTemplate;
   ...
   public void delete(Person p) {
      Name dn = buildDn(p);
      ldapTemplate.unbind(dn);
   }
}

2.5. 更新数据

在Java LDAP中,我们提供了两种方式来修改数据:rebind 与 modifyAttributes。

2.5.1. 使用rebind更新数据

使用rebind来更新数据时最原始的方法,它就是简单的先删除数据再添加数据。下面的示例展示了rebind的使用:
使用rebind更新数据

package com.example.repo;

public class PersonRepoImpl implements PersonRepo {
   private LdapTemplate ldapTemplate;
   ...
   public void update(Person p) {
      Name dn = buildDn(p);
      ldapTemplate.rebind(dn, null, buildAttributes(p));
   }
}

2.5.2. 使用modifyAttributes更新数据

一个更好的更新数据的方法就是使用modifyAttributes。这个方法会将要更新的属性放入一个数组中,并在特定的条目中更新这些属性的值:
使用modifyAttributes更新数据

package com.example.repo;

public class PersonRepoImpl implements PersonRepo {
   private LdapTemplate ldapTemplate;
   ...
   public void updateDescription(Person p) {
      Name dn = buildDn(p);
      Attribute attr = new BasicAttribute("description", p.getDescription())
      ModificationItem item = new ModificationItem(DirContext.REPLACE_ATTRIBUTE, attr);
      ldapTemplate.modifyAttributes(dn, new ModificationItem[] {item});
   }
}

译者注:上述示例仅仅更新了person p对应的dn这个条目中的description属性,读者可以创建多个要更新的Attribute对象,放入数组中,如:new ModificationItem[] {item,item2}

创建Attributes 与 ModificationItem也是很大的工作量,但是您可以参考Simplifying Attribute Access and Manipulation with DirContextAdapter,Spring LDAP提供了更多的内容来简化这些操作。

3. 使用DirContextAdapter来简化Attribute属性的获与操作

3.1 引言

Java LDAP API中鲜为人知并且可能被轻视的一个特点就是能够从找到的LDAP entries中注册DirObjectFactory 类来自动创建对象。Spring LDAP中某些search 与 lookup方法使用了这一特点并且返回一个DirContextAdapter实例。
当我们操作LDAP的属性时,DirContextAdapter 时一个非常有用的工具,尤其是当我们增加与修改数据的时候。

3.2. 在search 与 lookup中使用ContextMapper

只要在LDAP树种找到某个entry时,Spring LDAP会根据该entry的属性与DN来构建一个DirContextAdapter。这能让我们来用ContextMapper来替代上文中使用的AttributesMapper 来修改找到的属性值:
在查询中使用ContextMapper

package com.example.repo;

public class PersonRepoImpl implements PersonRepo {
   ...
   private static class PersonContextMapper implements ContextMapper {
      public Object mapFromContext(Object ctx) {
         DirContextAdapter context = (DirContextAdapter)ctx;
         Person p = new Person();
         p.setFullName(context.getStringAttribute("cn"));
         p.setLastName(context.getStringAttribute("sn"));
         p.setDescription(context.getStringAttribute("description"));
         return p;
      }
   }

   public Person findByPrimaryKey(
      String name, String company, String country) {
      Name dn = buildDn(name, company, country);
      return ldapTemplate.lookup(dn, new PersonContextMapper());
   }
}

就像上面展示的那样,我们能直接通过属性名获取到属性值,而不用像之前那样使用Attributes类与Attribute类。这在操作多值属性时非常有用。获取多值属性的值时,一般需要先得到Attributes 实现类的返回值,然后循环NamingEnumeration 。而通过DirContextAdapter 的
getStringAttributes()getObjectAttributes()方法可以轻松做到上述要求:
使用getStringAttributes()获取多值属性的值

private static class PersonContextMapper implements ContextMapper {
   public Object mapFromContext(Object ctx) {
      DirContextAdapter context = (DirContextAdapter)ctx;
      Person p = new Person();
      p.setFullName(context.getStringAttribute("cn"));
      p.setLastName(context.getStringAttribute("sn"));
      p.setDescription(context.getStringAttribute("description"));
      // The roleNames property of Person is an String array
      p.setRoleNames(context.getStringAttributes("roleNames"));
      return p;
   }
}

3.2.1. AbstractContextMapper

Spring LDAP提供了一个ContextMapper的一个抽象实现,AbstractContextMapper。该类可以自动将Object对象转为DirContexOperations。使用AbstractContextMapper后,上面的PersonContextMapper 可以改写为如下示例:
使用AbstractContextMapper

private static class PersonContextMapper extends AbstractContextMapper {
  public Object doMapFromContext(DirContextOperations ctx) {
     Person p = new Person();
     p.setFullName(context.getStringAttribute("cn"));
     p.setLastName(context.getStringAttribute("sn"));
     p.setDescription(context.getStringAttribute("description"));
     return p;
  }
}

译者注:我认为官网文档这里写错了,应该将上面的context改为ctx。读者对比该示例与之前的PersonContextMapper 的示例,可以发现AbstractContextMapper 的作用就是将doMapFromContext中的入参从Object ctx自动转换为DirContextOperations ctx,这样在代码中就省去了如下的强制转换:
DirContextAdapter context = (DirContextAdapter)ctx
猜测官网文档作者只是简单的复制粘贴,忘记了修改变量名。

3.3. 使用DirContextAdapter来增加与更新数据

尽管在获取属性值方面DirContextAdapter非常有用,在管理LDAP其他方面包括增加与更新数据中DirContextAdapter表现的更加抢眼。

3.3.1. 增加数据

针对上文Adding Data中的create方法,我们使用DirContextAdapter 可以更好的改良create方法,示例如下:
使用DirContextAdapter 增加数据

package com.example.repo;

public class PersonRepoImpl implements PersonRepo {
   ...
   public void create(Person p) {
      Name dn = buildDn(p);
      DirContextAdapter context = new DirContextAdapter(dn);

      context.setAttributeValues("objectclass", new String[] {"top", "person"});
      context.setAttributeValue("cn", p.getFullname());
      context.setAttributeValue("sn", p.getLastname());
      context.setAttributeValue("description", p.getDescription());

      ldapTemplate.bind(context);
   }
}

注意我们使用DirContextAdapter 实例作为bind的第二个参数(???),这应该是一个Context。因为我们没有指定某个属性,因此第三个参数为null。

译者注:这里我查阅了ldapTemplate.bind()方法,如下:
bind(DirContextOperations ctx)
bind(Name dn, Object obj,Attributes attributes)
而DirContextAdapter 是DirContextOperations的实现类,所以这里使用DirContextAdapter 作为第一参数套用的是bind(DirContextOperations ctx)方法,不存在三个参数的问题。我猜测这里所谓的第二参数是从bind方法的源码实现角度来讲的,bind(DirContextOperations ctx)方法在源码中应该是使用的bind(Name dn ,Object obj,Attributes attributes)方法。

同时注意,在对objectclass 属性赋值时,我们使用了setAttributeValues()方法。objectclass 属性时多值属性,于获取多值属性的值时的问题相同,普通方法来设置多值属性的值会显得枯燥和冗长。我们可以使用DirContextAdapter 的setAttributeValues()来完成该操作。

3.3.2. 更新数据

我们之间看到更新数据建议的方法是使用modifyAttributes ,但这个方法要求我们事先知道哪些属性被修改了,并根据这些要更新的属性创建ModificationItem 数组。DirContextAdapter 能帮我们做以上的事情。
使用DirContextAdapter更新数据

package com.example.repo;

public class PersonRepoImpl implements PersonRepo {
   ...
   public void update(Person p) {
      Name dn = buildDn(p);
      DirContextOperations context = ldapTemplate.lookupContext(dn);

      context.setAttributeValue("cn", p.getFullname());
      context.setAttributeValue("sn", p.getLastname());
      context.setAttributeValue("description", p.getDescription());

      ldapTemplate.modifyAttributes(context);
   }
}

通过上面的代码可以看到在代码中使用的是DirContextOperations ,这是因为:当没有参数传入ldapTemplate.lookup()时(no mapper is passed to a ldapTemplate.lookup()),返回的是DirContextAdapter 对象,但当查询方法返回一个Object对象时,lookupContext 方法会很方便的将返回值转化为DirContextOperations (这是DirContextAdapter 实现的一个接口)

译者注:我的理解是当我们create一个entry时,根据dn创建即可,没有用到查询方法,所用使用的是DirContextAdapter

细心的读者可以已经发现我们在create与update方法中有很多重复的代码。这些代码讲一个对象映射到上下文context上。这部分能够提取出来作为一个单独的方法:
使用DirContextAdapter增加与更新数据

package com.example.repo;

public class PersonRepoImpl implements PersonRepo {
   private LdapTemplate ldapTemplate;

   ...
   public void create(Person p) {
      Name dn = buildDn(p);
      DirContextAdapter context = new DirContextAdapter(dn);

      context.setAttributeValues("objectclass", new String[] {"top", "person"});
      mapToContext(p, context);
      ldapTemplate.bind(context);
   }

   public void update(Person p) {
      Name dn = buildDn(p);
      DirContextOperations context = ldapTemplate.lookupContext(dn);
      mapToContext(person, context);
      ldapTemplate.modifyAttributes(context);
   }

   protected void mapToContext (Person p, DirContextOperations context) {
      context.setAttributeValue("cn", p.getFullName());
      context.setAttributeValue("sn", p.getLastName());
      context.setAttributeValue("description", p.getDescription());
   }
}

3.4. DirContextAdapter 与作为属性值的DN值

在LDAP中管理安全组时,将DN值作为属性值是很常见的事情。由于判断DN相等于判断字符串相等有很大的区别(比如在判断DN相等时是忽略空格和大小写的),因此在判断属性是否发生变化需要更新时,简单的使用字符串是否相等来判断可能不会达到我们预期的效果。

比如:member属性的value值为:cn=John Doe,ou=People,并且我们在代码中写为:ctx.addAttributeValue("member", "CN=John Doe, OU=People"),这个属性就会为认为有两个值,尽管这两个字符串代表的是同一个DN值。

自Spring LDAP 2.0起,我们针对属性更新的方法提供了javax.naming.Name对象,当使用更新方法时,会让DirContextAdapter 使用DN是否相等来判断属性值是否发生变化。如果我们将上面的例子修改如下:
ctx.addAttributeValue("member",LdapUtils.newLdapName("CN=John Doe, OU=People"))
就不会触发更新操作。
*使用DirContextAdapter更新Group中的成员Membership *

public class GroupRepo implements BaseLdapNameAware {
    private LdapTemplate ldapTemplate;
    private LdapName baseLdapPath;

    public void setLdapTemplate(LdapTemplate ldapTemplate) {
        this.ldapTemplate = ldapTemplate;
    }

    public void setBaseLdapPath(LdapName baseLdapPath) {
        this.setBaseLdapPath(baseLdapPath);
    }

    public void addMemberToGroup(String groupName, Person p) {
        Name groupDn = buildGroupDn(groupName);
        Name userDn = buildPersonDn(
            person.getFullname(),
            person.getCompany(),
            person.getCountry());

        DirContextOperation ctx = ldapTemplate.lookupContext(groupDn);
        ctx.addAttributeValue("member", userDn);

        ldapTemplate.update(ctx);
    }

    public void removeMemberFromGroup(String groupName, Person p) {
        Name groupDn = buildGroupDn(String groupName);
        Name userDn = buildPersonDn(
            person.getFullname(),
            person.getCompany(),
            person.getCountry());

        DirContextOperation ctx = ldapTemplate.lookupContext(groupDn);
        ctx.removeAttributeValue("member", userDn);

        ldapTemplate.update(ctx);
    }

    private Name buildGroupDn(String groupName) {
        return LdapNameBuilder.newInstance("ou=Groups")
            .add("cn", groupName).build();
    }

    private Name buildPersonDn(String fullname, String company, String country) {
        return LdapNameBuilder.newInstance(baseLdapPath)
            .add("c", country)
            .add("ou", company)
            .add("cn", fullname)
            .build();
   }
}

在上面的例子中,我们实现了BaseLdapNameAware接口,该接口能让我们获取base LDAP路径,更多的描述在 Obtaining a reference to the base LDAP path。我们之所以要获取base LDAP路径是因为作为属性值存储的DN必须是一个从根节点开始的绝对路径。

3.5. 一个完整的PersonRepository类

为了说明Spring LDAP 与 DirContextAdapter 的易用性,我们写了下面的一个完整的PersonRepository类来操作LDAP:

package com.example.repo;
import java.util.List;

import javax.naming.Name;
import javax.naming.NamingException;
import javax.naming.directory.Attributes;
import javax.naming.ldap.LdapName;

import org.springframework.ldap.core.AttributesMapper;
import org.springframework.ldap.core.ContextMapper;
import org.springframework.ldap.core.LdapTemplate;
import org.springframework.ldap.core.DirContextAdapter;
import org.springframework.ldap.filter.AndFilter;
import org.springframework.ldap.filter.EqualsFilter;
import org.springframework.ldap.filter.WhitespaceWildcardsFilter;

import static org.springframework.ldap.query.LdapQueryBuilder.query;

public class PersonRepoImpl implements PersonRepo {
   private LdapTemplate ldapTemplate;

   public void setLdapTemplate(LdapTemplate ldapTemplate) {
      this.ldapTemplate = ldapTemplate;
   }

   public void create(Person person) {
      DirContextAdapter context = new DirContextAdapter(buildDn(person));
      mapToContext(person, context);
      ldapTemplate.bind(context);
   }

   public void update(Person person) {
      Name dn = buildDn(person);
      DirContextOperations context = ldapTemplate.lookupContext(dn);
      mapToContext(person, context);
      ldapTemplate.modifyAttributes(context);
   }

   public void delete(Person person) {
      ldapTemplate.unbind(buildDn(person));
   }

   public Person findByPrimaryKey(String name, String company, String country) {
      Name dn = buildDn(name, company, country);
      return ldapTemplate.lookup(dn, getContextMapper());
   }

   public List findByName(String name) {
      LdapQuery query = query()
         .where("objectclass").is("person")
         .and("cn").whitespaceWildcardsLike("name");

      return ldapTemplate.search(query, getContextMapper());
   }

   public List findAll() {
      EqualsFilter filter = new EqualsFilter("objectclass", "person");
      return ldapTemplate.search(LdapUtils.emptyPath(), filter.encode(), getContextMapper());
   }

   protected ContextMapper getContextMapper() {
      return new PersonContextMapper();
   }

   protected Name buildDn(Person person) {
      return buildDn(person.getFullname(), person.getCompany(), person.getCountry());
   }

   protected Name buildDn(String fullname, String company, String country) {
      return LdapNameBuilder.newInstance()
        .add("c", country)
        .add("ou", company)
        .add("cn", fullname)
        .build();
   }

   protected void mapToContext(Person person, DirContextOperations context) {
      context.setAttributeValues("objectclass", new String[] {"top", "person"});
      context.setAttributeValue("cn", person.getFullName());
      context.setAttributeValue("sn", person.getLastName());
      context.setAttributeValue("description", person.getDescription());
   }

   private static class PersonContextMapper extends AbstractContextMapper<Person> {
      public Person doMapFromContext(DirContextOperations context) {
         Person person = new Person();
         person.setFullName(context.getStringAttribute("cn"));
         person.setLastName(context.getStringAttribute("sn"));
         person.setDescription(context.getStringAttribute("description"));
         return person;
      }
   }
}

在某些情况下一个对象的DN值是使用对象的属性构造的。例如,在上面的例子中,Person的DN就用到了Person中的country, company 以及full name属性。这也就意味着我们在更新entry的属性值的时候,同时也会使用rename()方法在LDAP树中移动entry。由于这是一个极其具体的实现(需求),就需要你自己去跟踪上述过程,要么你就不允许用户更改person的属性,要么你在用户通过update()方法更改person属性后执行rename()方法。注意:如果你用了Object-Directory Mapping (ODM),这个函数库会自动帮你处理这些事情,但你需要在你的相关类中加上相应的注解。

4. 对象-目录Mapping(ODM)

4.1. 前言

基于对象-关系的映射框架,如Hibernate 以及JPA ,这些框架能够让开发者通过使用注解的方式在Java对象和关系型数据库的表格之间建立映射。Spring LDAP项目也针对LDAP目录通过LdapOperations类中的一些方法提供了类似的功能:

  • <T> T findByDn(Name dn, Class<T> clazz)
  • <T> T findOne(LdapQuery query, Class<T> clazz)
  • <T> List<T> find(LdapQuery query, Class<T> clazz)
  • <T> List<T> findAll(Class<T> clazz)
  • <T> List<T> findAll(Name base, SearchControls searchControls, Class<T> clazz)
  • <T> List<T> findAll(Name base, Filter filter, SearchControls searchControls, Class<T> clazz)
  • void create(Object entry)
  • void update(Object entry)
  • void delete(Object entry)

4.2. 注解

需要对用于对象映射方法的实体类进行注解,这些注解都来自与org.springframework.ldap.odm.annotations包。该包中可选的注解都有下面这些:

  • @Entry - 类级别的注解,表明了该实体映射了那些objectClass。(必需)
  • @Id - 标识实体的DN;声明此属性的字段必须是javax.naming.Name及其子类。(必需)
  • @Attribute - 表明该类中的成员变量与那个属性相互映射。
  • @DnAttribute - 表明该类中的此成员变量与DN属性相互映射。
  • @Transient - 表示该成员变量不是永久变量,使用此注解,会让OdmManager在映射时忽略此成员变量。

@Entry与@Id注解必需写到托管类(映射的类)中。@Entry用来标识该实体类映射哪些objectClass 以及该实体类映射的LDAP entry的根目录(可选)。所有字段被映射的objectClass都必须声明。(译者注:我的理解是如果我们使用到了cn与sn属性,那就必须要声明person这个objectClass。注意不要与java代码中的person混淆)需要注意的是在根据托管类(映射类)创建LDAP 的 entry时,只有被声明的objectClass会被创建。

为了将目录entry与托管类建立映射,目录entry中声明的objectClass都必须在@Entry注解中声明。例如:假设在你的LDAP树种有这么一个entry,它的objectClass包含:inetOrgPerson,organizationalPerson,person,top。如果你只想改变person这个objectClass中的属性,你的@Entry应该这样写:@Entry(objectClasses = { "person", "top" })。然而,如果你想管理inetOrgPerson 这个objectClass中属性,你就要用到上面所有的objectClass了:@Entry(objectClasses = { "inetOrgPerson", "organizationalPerson", "person", "top" })译者注:理解这句话需要LDAP的objectClass的相关知识)。

@Id注解用来将entry的DN值映射到一个成员变量上。这个成员变量必须是javax.naming.Name的实现类。

@Attribute注解用来实现objectClass中的属性与实体类中的成员变量之间的映射。为了保证精确的匹配,@Attribute要求必须在映射的成员变量上声明所映射的objectClass属性名,而声明LDAP属性的OID的语法是非必须项。@Attribute也提供了类型声明,该声明可以让你来表明该属性可被LDAP JNDI视为基于二进制的还是基于字符串的属性。

@DnAttribute可以实现成员变量与entry的DN中的某部分相互映射。当从目录树中读取一个entry时,@DnAttribute会自动将被注解的成员变量用DN中的某个值来正确填充。如果实体类中所有的@DnAttribute中的index都被指定了,那么在增加或更新数据的时候DN会自动重新计算(译者注:还记得上文中说的person中的属性名作为DN的一部分的时候,在更新数据时,需要rename() entry以便根据更新后的数据将entry移动到合适的树节点嘛?这里使用ODM自动完成了)。在更新数据的场景中,如果实体类的属性时DN值的一部分,这样就能够实现自动移动entry到目录树的合适位置。

@Transient注解用户表明该成员变量应该被ODM所忽略,不匹配LDAP中的属性。需要注意的是,如果@DnAttribute没有被绑定到属性上,或者说被@DnAttribute所注解的成员变量只是作为DN的一部分,但是在LDAP中没有这个属性,那它必须也要使用@Transient这个注解。

译者注:如果还不好理解,可以看下面的例子中的company成员变量

4.3. 执行

当所有的成员都被正确的配置与注解之后,LdapTemplae的对象映射方法用法如下:
执行

@Entry(objectClasses = { "person", "top" }, base="ou=someOu")
public class Person {
   @Id
   private Name dn;

   @Attribute(name="cn")
   @DnAttribute(value="cn", index=1)
   private String fullName;

   // No @Attribute annotation means this will be bound to the LDAP attribute
   // with the same value
   private String description;

   @DnAttribute(value="ou", index=0)
   @Transient
   private String company;

   @Transient
   private String someUnmappedField;
   // ...more attributes below
}


public class OdmPersonRepo {
   @Autowired
   private LdapTemplate ldapTemplate;

   public Person create(Person person) {
      ldapTemplate.create(person);
      return person;
   }

   public Person findByUid(String uid) {
      return ldapTemplate.findOne(query().where("uid").is(uid), Person.class);
   }

   public void update(Person person) {
      ldapTemplate.update(person);
   }

   public void delete(Person person) {
      ldapTemplate.delete(person);
   }

   public List<Person> findAll() {
      return ldapTemplate.findAll(Person.class);
   }

   public List<Person> findByLastName(String lastName) {
      return ldapTemplate.find(query().where("sn").is(lastName), Person.class);
   }
}

译者注:看了上面例子中的company是否理解了@Transient中的最后一句话?company在LDAP中并不存在这个属性,但我们在JAVA中创建的实体类为了业务需要而加入了这个成员变量,company可以与DN中的ou的值进行映射,如:cn=abc,ou=someOu,dc=cmiot,dc=com
ODM会自动将ou的值someOu赋值给company,但是通过Person实体类创建一个entry时,并不会将company的值写入LDAP,因为LDAP中并无该属性,且Person的@Entry注解已经说明了新增的entry的DN中的ou=someOu。

4.4. ODM与作为属性值的DN

LDAP中的安全组通常包含一个多值属性,每一个属性值都是系统中一个用户的DN值。操作这种属性时的困难点我们已经在
DirContextAdapter and Distinguished Names as Attribute Values.中将过了。

ODM同样支持javax.naming.Name作为属性值,这让群组的更新变得非常简单:
群组的例子

@Entry(objectClasses = {"top", "groupOfUniqueNames"}, base = "cn=groups")
public class Group {

    @Id
    private Name dn;

    @Attribute(name="cn")
    @DnAttribute("cn")
    private String name;

    @Attribute(name="uniqueMember")
    private Set<Name> members;

    public Name getDn() {
        return dn;
    }

    public void setDn(Name dn) {
        this.dn = dn;
    }

    public Set<Name> getMembers() {
        return members;
    }

    public void setMembers(Set<Name> members) {
        this.members = members;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public void addMember(Name member) {
        members.add(member);
    }

    public void removeMember(Name member) {
        members.remove(member);
    }
}

译者注:注意上面的类中除了get/set方法,还有addMember与removeMember方法。其中members是一个set,存放的是Name类型的数据,也就是DN。

在更新群组信息时,只需实例化该群组对象,然后根据需要调用setMembers, addMember 以及 removeMember方法,并调用ldapTemplate.update()即可,该过程会根据DN值来判断属性是否有更新,这就意味着在判断DN是否相等时会忽略文本格式。


5. 高级 LDAP 查询

5.1. LDAP Query Builder参数

LdapQueryBuilder 及其相关类意在提供能够用于LDAP查询的所有参数。它提供了下面的参数:

  • base - 指定LDAP树的根节点DN,并从此节点开始查询
  • searchScope - 指定在LDAP树中,查询的深度
  • attributes - 指定查询后返回的属性,默认为全部
  • countLimit - 指定查询返回的最大条目数
  • timeLimit - 指定查询所花费的最长时间
  • Search filter - 查询的条目必须满足的条件

当我们调用LdapQueryBuilder的query 方法时,LdapQueryBuilder就已经被创建了。LdapQueryBuilder意在成为一个好用的构建工具类API,它先定义基础的参数,然后才是过滤器的参数。一旦调用LdapQueryBuilder的where 方法定义了过滤器的参数后,再去试图调用如base等方法会被拒绝。查询的基本参数是可选的,但是过滤器方法应该至少被调用一次。

查询所有的objectClass为person的entry

import static org.springframework.ldap.query.LdapQueryBuilder.query;
...

List<Person> persons = ldapTemplate.search(
      query().where("objectclass").is("person"),
      new PersonAttributesMapper());

查询所有objectClass为person且cn=John Doe的entry

import static org.springframework.ldap.query.LdapQueryBuilder.query;
...

List<Person> persons = ldapTemplate.search(
      query().where("objectclass").is("person")
             .and("cn").is("John Doe"),
      new PersonAttributesMapper());

由dc=261consulting,dc=com此节点开始查询所有objectClass为person的所有entry,并只返回cn属性

import static org.springframework.ldap.query.LdapQueryBuilder.query;
...

List<Person> persons = ldapTemplate.search(
      query().base("dc=261consulting,dc=com")
             .attributes("cn")
             .where("objectclass").is("person"),
      new PersonAttributesMapper());

使用or来查询

import static org.springframework.ldap.query.LdapQueryBuilder.query;
...
List<Person> persons = ldapTemplate.search(
      query().where("objectclass").is("person"),
             .and(query().where("cn").is("Doe").or("cn").is("Doo));
      new PersonAttributesMapper());

5.2. 过滤器中的逻辑操作符

上面的例子中显示了在LDAP过滤可以处理相等的情况。LDAP 查询同样支持一下逻辑操作符:

  • is -表示相等
  • gte - 表示大于等于
  • lte - 表示小于等于
  • like - 表示模糊匹配,查询中可以包含通配符,例如:where("cn").like("J*hn Doe")就相当于根据条件(cn=J*hn Doe)来过滤
  • whitespaceWildcardsLike - 所有的空格被通配符替代,例如:where("cn").whitespaceWildcardsLike("John Doe")就相当于(cn=John*Doe)
  • isPresent - 用于检查属性是否存在,如:where("cn").isPresent()
  • not - 用于将过滤条件取反,例如:where("sn").not().is("Doe)就相当于(!(sn=Doe))

5.3. 硬编码的过滤器

如果你想要使用自定义的过滤器作为LdapQuery的输入也是可以的。LdapQueryBuilder 提供了两种方式来做到这一点:(译者注:LdapQueryBuilder 是LdapQuery接口的实现类

  • filter(String hardcodedFilter) - 使用特定的字符串作为过滤器。需要注意的是:输入的特定字符串不会有任何验证,这就意味着如果你是为用户输入而创建这个自定义过滤器的话,这个方法可能不适合

译者注:这里我没有很理解,查了doc文档原文为:Never use direct user input and use it concatenating strings to use as LDAP filters. Doing so opens up for "LDAP injection", where malicious user may inject specifically constructed data to form filters at their convenience. When user input is used consider using where(String) or filter(String, Object...) instead
大意为:建议我们不要将用户输入以及将用户输入连接字符串作为LDAP的过滤器。这样做会有“LDAP 注入”的风险。当你需要这么做到时候,可以考虑
where(String)filter(String, Object...)作为替代方案

  • filter(String filterFormat, String…​ params) - 将特定字符串作为MessageFormat的输入,将参数进行编码并在过滤条件语句的合适位置插入。

你不能同时使用硬编码的过滤器与上文介绍的where过滤器,这两者只能选用其一。这也就意味着,如果你自定义了一个过滤器写为filter(),当你再调用where时会产生异常。


最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,233评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,013评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,030评论 0 241
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,827评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,221评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,542评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,814评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,513评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,225评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,497评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,998评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,342评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,986评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,812评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,560评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,461评论 2 266

推荐阅读更多精彩内容