背景
LDAP听上去耳熟能详,可真要用Java去写一个通用性高的数据同步服务好像需要了解的东西又得特别多,能查到的东西又多是断章取义。最近断断续续的折腾了3周,基本上搞了一个这玩意,打算陆续分享给大家吧,由于相关东西不是一篇能讲完的,所以打算搞个小专题吧,希望给喜欢抄作业的朋友们有所帮助。
上一篇介绍了LDAP数据构建工具(LdapDataTool)的实现方式,这次介绍一下:LDAP数据同步服务设计思路。
1. 同步及认证设置
说点题外话,我认为产品经理其实需要良好的综合素质,其中最重要的是良好的产品思维,但好像多数的产品经理只会抄一些同类竞品的表面上的东西,无奈,对于这个需求,我只能基本重新组织整体的产品逻辑,以至于又得拿出了那多年以前开始使用的一款UI原型工具:Balsamiq Mockups,去重新设计UI,这里我挑选主要的予以介绍。
1.1 同步设置
首先是一些基础设置:
基础设置:
- Host:主机地址;
- Port:端口;
- Is LDAP over SSL:LDAP服务是否加密传输;
- Bind DN:LDAP管理员DN
- Bind Password:LDAP管理员密码
- Base DN:某组织跟节点DN
- Auth Atrribute Name:认证属性名称;
同步设置:
- Select team:超级管理可以任意选择,组织管理显示所属组织并且控件disable;
- Sync Mode:选择同步模式,可选同步用户和组织数据、只同步用户数据;
- User objectClass: 用户对象名称;
- Org objectClass:组织对象名称;
- Timing sync:同步周期:每天;取值范围:小时:00 - 23;分钟:00/05/10/15/20/25 … /55
如果上述设置填写错误,点击下一步时给予对应错误提示。
通过上述的设置,我们可以知道只到如何与LDAP服务建立连接、人和部门的对象是什么(LDAP支持自定义对象,因此不能想当然的认为人就是inetOrgPerson,部门就是:organizationalUnit)、鉴权字段是什么、同步周期是什么……
1.2 字段映射设置
接下来需要配置数据同步中需要涉及的数据源和目标数据的字段映射关系,有些IDaaS云厂商把这步配置叫做:新增匹配策略(一条一条的去ADD),有兴趣的同学可以参考:竹云IDaaS的开放平台文档:https://open.bccastle.com/guide/admin/id_source/ldap.html。
考虑到LDAP中对象shema的结构肯定是获取到的,因此,为了简化配置难度,我采用:获取对象shema结构自动填充的方式:
除了配置字段映射关系,在实际的应用中很可能会涉及一些转换需求:例如:手机号码只同步后四位,那么这里需要支持脚本转换。关于自定义脚本,之前搞了一套低代码平台,采用的是编译执行方式(大家可以参考:遵循编译原理主要过程实现“打印1+1结果”)。当然,这里由于不强调执行效率,基于解释性执行的方式也是可以接受的(之前基于Jmeter二开的时候,我也搞了一个类似的东西,可以考虑移植过来)。另外,由于这种脚本一般代码较少,解释执行方式的效率问题其实基本可以忽略。
2. 接口设计
管理后台-IDaaS接入基础配置检查
POST/admin/idaas/setting/check
管理后台-获取LDAP数据结构
GET/admin/idaas/setting/clear?oid={oid}
管理后台-设置IDaaS接入
POST/admin/idaas/setting
管理后台-获取IDaaS接入配置
GET/admin/idaas/setting?oid={oid}
管理后台-设置IDasS接入状态
POST/admin/idaas/status
管理后台-IDaaS数据同步
GET/admin/idaas/sync?oid={oid}
内部调用-IDaaS认证
POST/idaas/auth
这里我只贴一个设置IDaaS接入接口的YApi定义,其他接口定义都相对简单,大家可以根据自己需求去发挥。
3. LDAP客户端选型
要操作LDAP,首先需要决定用什么做为LDAP Client,目前主要的LDAP客户端组件为:
- JDK自带 (javax.naming.ldap.*)
- UnboundID LDAP SDK for Java
- Apache Directory API
- Mozilla Directory
由于之前我也没有这方面的经验,反正除了JDK自带的javax.naming.ldap.*,谁都是说自己是最好的那一个。因此,我把选型的首要因素定位于:谁最活跃(毕竟不活跃的东西,肯定用的人少,或者就是不好用),经过一番比较和纠结(纠结的是ldapsdk的官方sample代码较少),最终我选择了:ldapsdk。现在回头看来,这玩意真好用。关于官方sample代码较少,其实不必于过多纠结,人家都开源,大致的去看下源码,使用上基本是无任何障碍的,以下是相关资源:
- 官网:https://ldap.com/unboundid-ldap-sdk-for-java/
- github:https://github.com/pingidentity/ldapsdk
4. 同步策略
关于同步策略,我首先想到的是:增量定时轮巡,即:首次同步全量数据,之后同步增量同步更新数据。但是在相关调研、验证代码的编写和测试的过程中发现以下问题,不得采用了上游服务(ldap数据同步服务)采用每次全量同步,下游服务增量同步(上游服务通过双向比对并自己引入版本去达到下游服务增量同步的效果)
1、无论是LDAP还是AD,为支持增量同步均需要开启扩展控制器及一些额外设置,实操过程中无法保障所有对接客户均能满足要求。
- AD
https://docs.microsoft.com/en-us/windows/win32/ad/polling-for-changes-using-the-dirsync-control
Active Directory directory synchronization (DirSync) control is an LDAP server extension - OpenLDAP
Sync Provider Configuration:There is very little configuration needed for this overlay, in fact for many situations merely loading the overlay will suffice.However, because the overlay creates a contextCSN attribute in the root entry of the database which is updated for every write operation performed against the database and only updated in memory, it is recommended to configure a checkpoint
http://devdoc.net/linux/openldap-admin-2.4/guide.html#Sync%20Provider%20Configuration
其他参考:
https://github.com/pingidentity/ldapsdk/issues/132 --关于ldap定时同步问题我与ldap-sdk作者的讨论。
https://github.com/pingidentity/ldapsdk/issues/44 --AD下另一个开发者向作者提的类似问题。
2、对于某些目录服务器删除数据的获取只能通过日志获取
- 同样需要开启/设置相关配置
3、LDAP服务提供5种同步策略,且机制各不相同,并且有些策略之间互斥(例如,推和拉)。
- 主从模式:定期轮巡去主拉,主服务器即使只更新了条目的一个字段,也会把整个条目更新过来。
- Delta-syncrepl:与主从模式差异:不需要同步整个条目的数据。
- N-Way:多主同步
- MirrorMode:镜像模式,类似Raid1,服务器限制2台为主,互相以推的方式进行同步。
- Syncrepl Proxy:代理模式,主服务器不暴露给Client,客户端只访问代理服务器,当代理服务器数据变更时,以推的方式向出同步。‘
5. 定时任务
定时任务的组件很多:Quartz、Elastic-Job、XXL-JOB、PowerJob,基于谁去封装这个根据自己实际需要,不过有一点就是让非开发人员去写cron表达式不太友好,那么这里我推荐:
<dependency>
<groupId>com.cronutils</groupId>
<artifactId>cron-utils</artifactId>
<version>9.1.6</version>
</dependency>
以下是我写的一个封装:
JobCronBuilder.java
@Slf4j
public class JobCronBuilder {
private static final CronDefinition CRON_DEFINITION = CronDefinitionBuilder.instanceDefinitionFor(CronType.QUARTZ);
private static final CronParser CRON_PARSER = new CronParser(CRON_DEFINITION);
private static final CronDescriptor CRON_DESCRIPTOR = CronDescriptor.instance(Locale.UK);
private static List<FieldExpression> workDaysFieldExp = new ArrayList<>();
static {
// 将来改成基于组织级的配置
workDaysFieldExp.add(on(2));
workDaysFieldExp.add(on(3));
workDaysFieldExp.add(on(4));
workDaysFieldExp.add(on(5));
workDaysFieldExp.add(on(6));
}
private JobCronBuilder() {
}
/**
* getCronInfo
*
* @param scheduleRule
* @param now DB服务器现在时间戳
* @return
*/
public static CronInfo getCronInfo(ScheduleRule scheduleRule, Long now) {
Cron cron = getCron(scheduleRule, now);
return getCronInfo(cron);
}
/**
* getCronExecutionList(获取CRON表达式执行时间列表)
*
* @param cronExp
* @param count 执行次数
* @param beginTime 开始时间
* @return
* @throws Exception
*/
public static List<ZonedDateTime> getCronExecutionList(String cronExp, int count, Long beginTime) {
Cron cron = getCron(cronExp);
ExecutionTime executionTime = ExecutionTime.forCron(cron);
ZonedDateTime zonedDateTime = DateTimeUtil.long2ZonedDateTime(beginTime);
List<ZonedDateTime> nextExecutionList = new ArrayList<>();
for (int i = 0; i < count; i++) {
Optional<ZonedDateTime> nextExecutionZonedDateTime = executionTime.nextExecution(zonedDateTime);
if (!nextExecutionZonedDateTime.isPresent()) {
break;
}
zonedDateTime = nextExecutionZonedDateTime.get();
nextExecutionList.add(nextExecutionZonedDateTime.get());
}
return nextExecutionList;
}
/**
* getCronInfo
*
* @param cron
* @return
*/
private static CronInfo getCronInfo(Cron cron) {
ExecutionTime executionTime = ExecutionTime.forCron(cron);
Optional<ZonedDateTime> nextExecutionZonedDateTime = executionTime.nextExecution(ZonedDateTime.now());
if (!nextExecutionZonedDateTime.isPresent()) {
throw new BwException(SCHEDULE_HAS_NOT_NEXT_EXECUTION);
}
return CronInfo.builder()
.expression(cron.asString())
.description(CRON_DESCRIPTOR.describe(cron))
.nextExecution(DateTimeUtil.zonedDateTime2Long(nextExecutionZonedDateTime.get()))
.build();
}
/**
* getCron
*/
private static Cron getCron(String cronExp) {
Cron cron = CRON_PARSER.parse(cronExp);
if (cron == null) {
throw new BwException(JobResultCode.INVALID_CRON_EXPRESSION);
}
return cron;
}
/**
* getCron
*/
private static Cron getCron(ScheduleRule scheduleRule, Long now) {
if (scheduleRule == null) {
throw new BwException(JobResultCode.SCHEDULE_RULE_IS_NULL);
}
ScheduleRule rule = scheduleRule.copy();
rule.setDeadline(getNextAvailableDeadline(rule, now));
LocalDateTime localDate = DateTimeUtil.long2LocalDateTime(rule.getDeadline());
switch (rule.getRepeatType()) {
/**
* 由于cron表达式有局限性(例如:不支持表达每隔100天重复)every类重复任务采取接力方式进行重复。
*/
case NOT_REPEAT:
case EVERY_SOME_DAY_REPEAT:
case EVERY_SOME_WEEK_REPEAT:
case EVERY_SOME_MONTH_REPEAT:
case EVERY_SOME_YEAR_REPEAT:
return CronBuilder.cron(CRON_DEFINITION)
.withYear(on(localDate.getYear()))
.withDoM(on(localDate.getDayOfMonth()))
.withMonth(on(localDate.getMonthValue()))
.withDoW(questionMark())
.withHour(on(localDate.getHour()))
.withMinute(on(localDate.getMinute()))
.withSecond(on(localDate.getSecond()))
.instance();
case DAY_REPEAT:
return CronBuilder.cron(CRON_DEFINITION)
.withYear(always())
.withDoM(always())
.withMonth(always())
.withDoW(questionMark())
.withHour(on(localDate.getHour()))
.withMinute(on(localDate.getMinute()))
.withSecond(on(localDate.getSecond()))
.instance();
case WEEK_REPEAT:
return CronBuilder.cron(CRON_DEFINITION)
.withYear(always())
.withDoM(questionMark())
.withMonth(always())
.withDoW(on(getCronDayOfWeek(localDate.getDayOfWeek())))
.withHour(on(localDate.getHour()))
.withMinute(on(localDate.getMinute()))
.withSecond(on(localDate.getSecond()))
.instance();
case MONTH_REPEAT:
return CronBuilder.cron(CRON_DEFINITION)
.withYear(always())
.withDoM(on(localDate.getDayOfMonth()))
.withMonth(always())
.withDoW(questionMark())
.withHour(on(localDate.getHour()))
.withMinute(on(localDate.getMinute()))
.withSecond(on(localDate.getSecond()))
.instance();
case WORK_DAY_REPEAT:
return CronBuilder.cron(CRON_DEFINITION)
.withYear(always())
.withDoM(questionMark())
.withMonth(always())
.withDoW(and(workDaysFieldExp))
.withHour(on(localDate.getHour()))
.withMinute(on(localDate.getMinute()))
.withSecond(on(localDate.getSecond()))
.instance();
default:
throw new BwException(JobResultCode.UNSUPPORTED_REPEAT_TYPE);
}
}
/**
* getCronWeekday
* JDK中LocalDateTime.DayOfWeek与CRON表达式dayOfWeek不一致
*
* @param dayOfWeek MONDAY = 1; TUESDAY = 2; WEDNESDAY = 3; THURSDAY = 4; FRIDAY = 5; SATURDAY = 6; SUNDAY = 7;
* @return SUNDAY = 1; MONDAY = 2; TUESDAY = 3; WEDNESDAY = 4; THURSDAY = 5; FRIDAY = 6; SATURDAY = 7;
*/
private static int getCronDayOfWeek(DayOfWeek dayOfWeek) {
return ConstantsMapper.weekDayMapping(ConstantsMapper.JAVA8, ConstantsMapper.QUARTZ_WEEK_DAY, dayOfWeek.getValue());
}
/**
* getNextAvailableDeadline
*
* @param scheduleRule
* @param now DB服务器现在时间戳
* @return
*/
private static Long getNextAvailableDeadline(ScheduleRule scheduleRule, Long now) {
// 不重复任务:如果截止时间为过去则表示立刻执行(延迟N秒)
if (scheduleRule.getRepeatType() == NOT_REPEAT && scheduleRule.getDeadline() < now) {
return now + JOB_DELAY_TIMESTAMP;
}
// Every重复任务:如果截止时间为过去则返回离当前时间最近的1个将来时间
boolean isEverySomeRepeat = scheduleRule.getRepeatType() == EVERY_SOME_DAY_REPEAT
|| scheduleRule.getRepeatType() == EVERY_SOME_WEEK_REPEAT
|| scheduleRule.getRepeatType() == EVERY_SOME_MONTH_REPEAT
|| scheduleRule.getRepeatType() == EVERY_SOME_YEAR_REPEAT;
if (isEverySomeRepeat && scheduleRule.getDeadline() < now) {
if (scheduleRule.getEveryValue() <= 0) {
throw new BwException(EVERY_VALUE_MUST_GT_ZERO);
}
Date date = new Date(scheduleRule.getDeadline());
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
int field = -1;
switch (scheduleRule.getRepeatType()) {
case EVERY_SOME_DAY_REPEAT:
field = Calendar.DAY_OF_YEAR;
break;
case EVERY_SOME_WEEK_REPEAT:
field = Calendar.WEEK_OF_YEAR;
break;
case EVERY_SOME_MONTH_REPEAT:
field = Calendar.MONTH;
break;
case EVERY_SOME_YEAR_REPEAT:
field = Calendar.YEAR;
break;
default:
throw new BwException(JobResultCode.UNSUPPORTED_REPEAT_TYPE);
}
// 前面校验保障everyValue>0,不会死循环
for (; ; ) {
calendar.add(field, scheduleRule.getEveryValue());
Long nextExecution = calendar.getTimeInMillis();
if (nextExecution > now) {
return nextExecution;
}
}
}
// 其他类型任务无需处理
return scheduleRule.getDeadline();
}
}
CronInfo.java
@ToString
@Builder
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CronInfo {
@ApiModelProperty(value = "Cron表达式", required = true)
private String expression;
@ApiModelProperty(value = "Cron表达式描述", required = true)
private String description;
@ApiModelProperty(value = "下次执行时间", required = true)
private Long nextExecution;
}
ScheduleRule.java
@ToString
@Builder
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ScheduleRule {
/**
* 截止时间
*/
@ApiModelProperty("截止时间")
private Long deadline = 0L;
/**
* 重复类型:
* 0:不重复;1:每天重复;2:每周重复;3:每月重复;4:每个工作日重复;
* 5:every-天重复;6:every-周重复;7:every-月重复;8:every-年重复
*/
@ApiModelProperty("重复类型")
private Integer repeatType = 0;
/**
* 重复间隔周期
*/
@ApiModelProperty("重复间隔周期")
private Integer everyValue = 0;
/**
* copy(不命名为clone的原因为规避sonar)
*/
public ScheduleRule copy() {
return ScheduleRule.builder()
.deadline(this.deadline)
.repeatType(this.repeatType)
.everyValue(this.everyValue)
.build();
}
}
DateTimeUtil.java
public class DateTimeUtil {
/**
* 获取当前系统默认时区(要求部署时系统时区设置正确)
*/
private static final ZoneId DEFAULT_ZONE = ZoneId.systemDefault();
private DateTimeUtil() {
}
/**
* long2ZonedDateTime
*
* @param ts
* @return
*/
public static ZonedDateTime long2ZonedDateTime(Long ts) {
return ZonedDateTime.ofInstant(Instant.ofEpochMilli(ts), DEFAULT_ZONE);
}
/**
* long2LocalDateTime
*/
public static LocalDateTime long2LocalDateTime(Long ts) {
return LocalDateTime.ofInstant(Instant.ofEpochMilli(ts), DEFAULT_ZONE);
}
/**
* zonedDateTime2Long
*
* @param zonedDateTime
* @return
*/
public static Long zonedDateTime2Long(ZonedDateTime zonedDateTime) {
return Timestamp.valueOf(zonedDateTime.toLocalDateTime()).getTime();
}
/**
* localDateTime2Long
*
* @param localDateTime
* @return
*/
public static Long localDateTime2Long(LocalDateTime localDateTime) {
return Timestamp.valueOf(localDateTime).getTime();
}
/**
* long2FormattedDate
*
* @param ts
* @param pattern
* @return
*/
public static String long2FormattedDate(Long ts, String... pattern) {
SimpleDateFormat dateFormat = pattern.length == 0 ? new SimpleDateFormat(DEFAULT_DATE_FORMAT_PATTERN) : new SimpleDateFormat(pattern[0]);
return dateFormat.format(new Date(ts));
}
}
6. 认证
LDAP认证鉴权比较简单,这里直接上一段测试代码吧,当然由于鉴权操作的频度远远高于同步操作,因此在实际的实现中应该采用LDAP 连接池(ldapsdk直接支持:参考其LDAPConnectionPool即可)。
public static void main(String[] args) throws Exception {
String accountValue = "jzhang";
String accountAttributeName = "uid";
String accountPwd = "123456";
String userObjectClass = "inetOrgPerson";
LDAPConnection connection = null;
try {
String filter = "&(objectClass=" + userObjectClass + ")(" + accountAttributeName + "=" + accountValue + ")";
connection = new LDAPConnection(LDAP_HOST, LDAP_PORT);
SearchRequest baseSearchRequest = new SearchRequest(LDAP_BASE_DN, SearchScope.SUBORDINATE_SUBTREE, filter);
SearchResult searchResult = connection.search(baseSearchRequest);
int resultEntryCount = searchResult.getEntryCount();
if (resultEntryCount == 0) {
System.out.println("user not existed!");
return;
}
if (resultEntryCount > 1) {
System.out.println("user account not unique!");
return;
}
String userDn = searchResult.getSearchEntries().get(0).getDN();
BindResult bindResult = connection.bind(userDn, accountPwd);
ResultCode resultCode = bindResult.getResultCode();
if (resultCode.equals(ResultCode.SUCCESS)) {
System.out.println("bind success:" + resultCode);
return;
}
System.out.println("bind failed: " + resultCode.toString());
} finally {
if (connection != null && connection.isConnected()) {
connection.close();
}
}
}
由于搞的这个玩意是公司的东西,我不能将其项目源码直接公开,只能在github或者其他地方,提供一个demo,要命是,刚接到通知,说今天开始996,日了狗!因此,可能需要晚些时候才能给大家抄作业了(毕竟我将现有项目剥离出一个demo这也不是三下五除二的功夫)。不过大致的思路,我这里已经基本都给给予了介绍,按照上面的思路去走,我可以责任的说:一定是能走通的。另外,对于LDAP基础不了解的同学,建议去了解一下以下基础:
DN
Distinguished Name,惟一辨别名,类似于Linux文件系统中的绝对路径,每个对象都有一个惟一的名称,如“uid= tom,ou=market,dc=example,dc=com”,在一个目录树中DN总是唯一。
Schema
Schema可以简单的理解为对象的一种描述形式,它由:Object class、Attribute、Syntax组成:
Object class
- objectclass定义了一个类别
- 使用schema描述
- 支持继承和多继承
- 构成:名称(NAME)、说明(DESC)、类型(STRUCTURAL或AUXILARY)、属性(必须属性(MUST)、可选属性(MAY))
Attribute - 构成:名称、数据类型、单值/多值…
- 常用属性:cn(Common Name)、dc (Domain Component)、giveName(不能包括姓)、I(地理区域名称)、o(orgnazationName)、ou (Organizational Unit)、sn(姓)、uid(通常是user account)
Syntax
语法是遵从X.500中数据约束的定义,常用类型如下,全定义参考:
https://ldap.com/ldap-oid-reference-guide/
filter语法
运算符:
- 等于(EQUAL TO): =
- 大于等于(Greater than): >=
- 小于等于(Less than): <=
- 通配符(wildcard): *
- 逻辑与(logical AND): &
- 逻辑或(logical OR): |
- 逻辑非(logical NOT): !
举例:
- 查询所有name为张三,sex为男的用户: (&(name=张三)(sex=男))
- 查询所有age不为28的用户: (!(age=28))
- 查询所有age为28,并且name不为张三的用户: (&(age=28)(!(name=张三)))
- 查询所有age为28,或者name为张三的用户:(|(age=28)(name=张三))
- 查询所有name的姓为张,或者desc包含描述的用户: (|(name=张*)(desc=描述))
- 查询所有有email为空的用户:(email=)
- 查询所有没有desc属性的用户: (!(desc=*))
- 查询所有有desc属性的用户:(desc=*)