本文为缺乏WEB工程经验的算法工程师提供一个起步的WEB项目实践. 我们用一个简单的例子来说明如何开发一个完整的算法服务(基于HTTP协议).
需求 个用户分配资产, 总金额为. 我们需要为用户提供一个资产分配的算法服务.
考虑因素
- 分配方式有多种: 例如平均分, 按比例分. 不同的场景下可能会采用不同的分配方式.
- 考虑业务约束: 年龄段在18岁至60岁之间的用户才能分到钱.
- 后续可能会增加新的业务约束和分配方式.
(虽然这个问题很简单, 但是我们把它想象成一个复杂的项目来实施, 借此掌握一些基本的开发流程.)
在这个例子中, 我们介绍的开发流程如下:
- 算法架构设计
- 创建项目
- 定义接口
- 实现接口
- 单元测试
- 性能调优(缓存/线程池配置)
- 打包和部署
1. 算法架构设计
架构设计的核心思想是模块化, 把需要实现的算法服务拆分成多个模块, 使得各模块之间低耦合且高内聚. 这样做的好处是一个项目可以由多人合作, 不仅能加快开发速度, 而且能降低整个系统的复杂性.
考虑到要实现的功能包含两个核心要素:
- 分配资产. 即, 实现资产分配算法;
- 筛选符合条件的用户. 即, 对满足条件的用户分配资产.
首先, 我们把核心功能拆成两个模块:
- 业务约束(Constraint) 输入用户列表, 输出有效的用户.
- 分配器(Allocator) 输入用户列表和资产总金额, 输出每个用户分配到的金额.
其次, 数据模块(Data)负责获取数据库中的用户信息, 为Constraint模块和Allocator模块提供基础数据支持. 在本例中, 我们把用户数据用JSON格式保存在本地.
第三, 算法的实际调用由服务层中的分配服务(AllocationService)模块实现.
最后, 在Web层实现对HTTP请求的响应, 即返回分配服务(AllocationService)计算的结果.
因此我们得到一个简单的分层架构(见下图).
算法架构2. 构建代码框架
本项目基于Java的Spring Boot框架实现, 原因是框架帮我们提供了丰富的工程层面的工具, 例如实现HTTP接口, 日志, 线程池, 缓存等. 我们可以用IDE工具IntelliJ IDEA新建项目, 详细方法可以参考:
按照上面的架构图, 我们把项目的文件结构按照下图组织.
+ beans # 基础数据结构
+ configs # 配置类
+ core # 核心模块的实现
| + allocator # 分配器
| + constraint # 业务约束
| + data # 用户数据
+ service # 调用core中的模块, 实现功能
+ web # 调用service实现HTTP接口
3. 定义接口
我们采用自顶向下(Top-Down)的设计方法.
3.1 Web接口
-
URL: /allocate
-
请求方式: POST
-
输入: JSON
字段名 类型 是否必填 默认值 说明 userIds Array<String> 是 - 用户id列表 totalReward Double 是 - 待分配的总资产 示例
{ "userIds": ["10001", "10002", "10003", "10004", "10005", "10006", "10007", "10008", "10009"], "totalReward": 100 }
-
输出: JSON
字段名 类型 说明 code Integer 状态码: 200-成功; 400-失败 userPayoffs List<UserPayoff> 用户分配到的资产列表(UserPayoff格式参考示例) 示例
{ "code": 200, "uerPayoffs": [{"userId":"10001","gain":30.0},{"userId":"10002","gain":30.0},{"userId":"10003","gain":40.0}] }
3.2 分配服务(AllocationService)
依照Web接口的定义, 我们直接写出服务层的接口.
AllocationService.java
public interface AllocationService {
/**
* @param userIds 用户id的列表
* @param totalReward 待分配的奖金
* @return 分配结果
*/
List<UserPayoff> allocate(List<String> userIds, Double totalReward);
}
3.3 分配器(Allocator)
上层的分配服务需要调用分配算法, 其输入和输出如下所示:
Allocator.java
public interface Allocator {
/** 资产分配算法.
* @param weights: 用户权重的列表
* @param totalReward: 总资产
* @return 用户分配到的资产
*/
List<Double> allocate(List<Double> weights, Double totalReward);
}
3.4 业务约束(Constraint)
它的作用是处理业务约束.
Constraint.java
public interface Constraint {
/** 按业务约束过滤无效的用户.
* @param userIds: 用户id列表
* @return 满足分配条件的用户id列表
*/
List<String> getFeasibleUserIds(List<String> userIds);
}
3.5 数据模块(UserData)
根据用户id返回用户对象.
UserData.java
public interface UserData {
/**
* 获取用户信息.
* @param userId: 用户id
* @return 用户对象
*/
User getUser(String userId);
}
4. 接口实现
接口定义好之后, 各模块的负责人就可以并行开发了. 以Constraint为例, 它有两个依赖:
- ConstraintConfig: 读取业务相关的配置,例如
constraint.age.min=18
,constraint.age.max=60
代表有资格分配资产的用户的最小和最大年龄. 这个配置类可以帮我们读取配置文件中的参数. - UserData: 获取用户数据
因此, 我们需要在Constraint实现类的构造函数中注入上述模块的对象.
ConstraintImpl.java
/**
* 处理业务逻辑: 考虑分配的最小年龄和最大年龄.
*/
@Component
public class ConstraintImpl implements Constraint {
private ConstraintConfig constraintConfig;
private UserData userDataImpl;
@Autowired
public ConstraintImpl(ConstraintConfig constraintConfig, UserData userDataImpl) {
this.constraintConfig = constraintConfig;
this.userDataImpl = userDataImpl;
}
@Override
public List<String> getFeasibleUserIds(List<String> userIds) {
// 实现业务逻辑
}
}
Remark 注解@Component和@Autowired会自动完成对象的实例化, 因此我们不要手动
new
对象. 利用框架自动实例化对象的好处是: 当底层依赖发生变化时, 上层无需感知, 从而做到上下层解耦. 有关设计模式更多的理解请搜索关键字: dependency injection 和 Inversion of Control (Ioc).
5. 单元测试
假设ConstraintImpl类已经开发完毕, 但它的依赖模块UserDataImpl并没有完成开发. 在这种情况下, 我们可以利用Mock工具方便地对UserDataImpl类的输出结果进行模拟. 示例如下:
ConstraintImplTest.java
@RunWith(SpringRunner.class)
@SpringBootTest
public class ConstraintImplTest {
@MockBean
private UserData userDataImpl; // 需要被Mock的对象
@Autowired
private Constraint constraintImpl; // 被测试的对象
@Value("classpath:test-cases/users.json")
private Resource users; // 测试用例(用户信息保存在users.json文件)
private Map<String, User> userMap = new HashMap<>();
// 读取测试用例
@Before
public void loadContext() throws Exception {
var jsonString = FileUtils.readFileToString(users.getFile(), "UTF-8");
var userList = new Gson().fromJson(jsonString, UserList.class).getUserList();
for (var user: userList) {
userMap.put(user.getUserId(), user);
}
}
@Test
public void getFeasibleUserIds() {
// Mock用户数据
for (var userId: userMap.keySet()) {
// 当输入userId时, 返回测试用例中对应的用户数据
when(userDataImpl.getUser(userId)).thenReturn(userMap.get(userId));
}
var result = constraintImpl.getFeasibleUserIds(new LinkedList<>(userMap.keySet()));
Assert.assertEquals(Set.of("10001", "10002", "10007", "10008", "10009"), new HashSet<>(result));
}
}
注解说明
- @MockBean: 写在需要被Mock的对象之前, 框架会根据变量名自动实例化对应的类(命名规则是"对象名与类名一致, 首字母小写").
- @Autowired: 写在被测试的之前对象之前. 与@MockBean一样, 框架会按照变量名自动找到对应的类并实例化.
- @Test: 标记测试方法.
- @Before: 测试之前执行, 同理还所有@After
Remark 利用Mock工具做单元测试, 各模块的开发者可以独立工作并测试其负责的模块, 因此无需关心开发的顺序. 作者完成本例各模块单元测试之后, 所有模块联调一次通过. 虽然写单元测试时觉得有些麻烦, 但会在后期极大地降低整个系统的联调代价.
6. 性能调优
6.1 使用缓存
注意到Allocator和Constraint模块要多次调用UserData, 因此会有重复调用, 不仅浪费资源而且会降低系统的响应时间. 本项目使用Ehcache3. 当配置好缓存, 只需要使用注解(JCache)即可轻松实现缓存操作.
- CacheDefaults: 写在类名前, 参数cacheName代表使用的缓存名称. 例如 @CacheDefualts(cacheName = "userCache"), 其中缓存"userCache"是提前配置好的.
- CacheResult 写在方法名前, 返回的结果会被缓存下来.
UserData实现类(带缓存)示例如下:
UserDataImpl.java
@CacheDefaults(cacheName = "userCache")
@Slf4j
@Component
public class UserDataImpl implements UserData {
@Value("classpath:./test-cases/users.json")
private Resource users;
@CacheResult
@Override
public User getUser(String userId) {
try {
var jsonString = FileUtils.readFileToString(users.getFile(), "UTF-8");
log.info("IO operation: read file");
var userList = new Gson().fromJson(jsonString, UserList.class).getUserList();
for (var user: userList) {
if (user.getUserId().equals(userId)) return user;
}
} catch (IOException e) {
log.error(e.toString());
}
return null;
}
}
6.2 配置缓存(Java11+Ehcache3)
- 在
pom.xml
中添加依赖.
<!-- 缓存: Spring Boot 集成 Ehcache -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.ehcache</groupId>
<artifactId>ehcache</artifactId>
<version>3.7.0</version>
</dependency>
<dependency>
<groupId>javax.cache</groupId>
<artifactId>cache-api</artifactId>
<version>1.1.0</version>
</dependency>
<!-- 缓存: END -->
<!-- JAXB API: 为了加载Ehcache3的xml配置文件(Java11专用) -->
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.2.11</version>
</dependency>
<dependency>
<groupId>com.sun.xml.bind</groupId>
<artifactId>jaxb-core</artifactId>
<version>2.2.11</version>
</dependency>
<dependency>
<groupId>com.sun.xml.bind</groupId>
<artifactId>jaxb-impl</artifactId>
<version>2.2.11</version>
</dependency>
<dependency>
<groupId>javax.activation</groupId>
<artifactId>activation</artifactId>
<version>1.1.1</version>
</dependency>
<!-- JAXB API: END -->
- 创建配置类
例子中设置了两个缓存:
- UserCache: key是String类型, 缓存的数据类型必须是User对象.
- GenericCache: key是任意java对象, 缓存的数据类型也是任意java对象.
CacheConfig.java
@Component
public class CacheConfig {
@Component
public static class GenericCache implements JCacheManagerCustomizer {
@Override
public void customize(CacheManager cacheManager) {
var cacheName = "genericCache";
// 避免单元测试时重复创建cache
if(Optional.ofNullable(cacheManager.getCache(cacheName)).isPresent()) return;
cacheManager.createCache(cacheName, new MutableConfiguration<>()
//Set expiry policy.
//CreatedExpiryPolicy: Based on creation time
//AccessedExpiryPolicy: Based on time of last access
//TouchedExpiryPolicy: Based on time of last OR update
//ModifiedExpiryPolicy: Based on time of last update
//ExternalExpiryPolicy: Ensures the cache entries never expire (default expiry policy)
.setExpiryPolicyFactory(CreatedExpiryPolicy.factoryOf(new Duration(MINUTES, 10)))
// store by reference or value.
.setStoreByValue(false)
);
}
}
@Component
public static class UserCache implements JCacheManagerCustomizer {
@Override
public void customize(CacheManager cacheManager) {
var cacheName = "userCache";
// 避免单元测试时重复创建cache
if(Optional.ofNullable(cacheManager.getCache(cacheName)).isPresent()) return;
cacheManager.createCache(cacheName, new MutableConfiguration<String, User>()
.setTypes(String.class, User.class)
.setExpiryPolicyFactory(TouchedExpiryPolicy.factoryOf(new Duration(MINUTES, 10)))
.setStoreByValue(true)
);
}
}
}
- 添加在
resources
目录下配置文件ehcache.xml
添加配置文件的作用是为每个配置类中的缓存提供更多设置, 例如缓存大小. 如何配置请参考官方的教程. 注意: 这一步不是必须的.
<config
xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance'
xmlns='http://www.ehcache.org/v3'
xmlns:jsr107='http://www.ehcache.org/v3/jsr107'>
<service>
<jsr107:defaults>
<jsr107:cache name="userCache" template="heap-cache"/>
<jsr107:cache name="genericCache" template="heap-cache"/>
</jsr107:defaults>
</service>
<cache-template name="heap-cache">
<resources>
<heap unit="entries">2000</heap>
<offheap unit="MB">500</offheap>
</resources>
</cache-template>
</config>
- 在Spring Boot配置文件中关联
ehcahce.xml
(注意: 这一步不是必须的)
application.properties
spring.cache.jcache.config=classpath:ehcache.xml
- 6.3 配置线程池
多线程可以处理并发. 我们可以利用Sping Boot自带的配置方便地管理线程.
application.properties
# Whether core threads are allowed to time out. This enables dynamic growing and shrinking of the pool.
spring.task.execution.pool.allow-core-thread-timeout=true
# Core number of threads.
spring.task.execution.pool.core-size=8
# Time limit for which threads may remain idle before being terminated.
spring.task.execution.pool.keep-alive=60s
# Maximum allowed number of threads. If tasks are filling up the queue, the pool can expand up to that size to accommodate the load. Ignored if the queue is unbounded.
spring.task.execution.pool.max-size=20
# Queue capacity. An unbounded capacity does not increase the pool and therefore ignores the "max-size" property.
spring.task.execution.pool.queue-capacity=30
# Prefix to use for the names of newly created threads.
spring.task.execution.thread-name-prefix=task-
7. 打包和部署
- 用Maven打包参考: 使用IntelliJ IDEA构建Spring Boot项目示例
- 项目的完整代码(Java11): https://github.com/xianqiu/toy-allocator
8. 练习
试着在项目的基础上增加新的功能
- 在原有的web接口中增加新的输入参数"allocator": mean - 平均分配; proportion - 按比例分配. 因此用户可以在请求时通过指定allocator来选择分配方式.
- 增加“按组分配”的功能:用户可以两人一组进行组队,组队之后的年龄取平均年龄.
网友评论