算法WEB服务开发实践(入门)

2019-05-11  本文已影响0人  胡拉哥

本文为缺乏WEB工程经验的算法工程师提供一个起步的WEB项目实践. 我们用一个简单的例子来说明如何开发一个完整的算法服务(基于HTTP协议).

需求 n个用户分配资产, 总金额为M. 我们需要为用户提供一个资产分配的算法服务.

考虑因素

  • 分配方式有多种: 例如平均分, 按比例分. 不同的场景下可能会采用不同的分配方式.
  • 考虑业务约束: 年龄段在18岁至60岁之间的用户才能分到钱.
  • 后续可能会增加新的业务约束和分配方式.

(虽然这个问题很简单, 但是我们把它想象成一个复杂的项目来实施, 借此掌握一些基本的开发流程.)

在这个例子中, 我们介绍的开发流程如下:

1. 算法架构设计

架构设计的核心思想是模块化, 把需要实现的算法服务拆分成多个模块, 使得各模块之间低耦合高内聚. 这样做的好处是一个项目可以由多人合作, 不仅能加快开发速度, 而且能降低整个系统的复杂性.

考虑到要实现的功能包含两个核心要素:

  1. 分配资产. 即, 实现资产分配算法;
  2. 筛选符合条件的用户. 即, 对满足条件的用户分配资产.

首先, 我们把核心功能拆成两个模块:

其次, 数据模块(Data)负责获取数据库中的用户信息, 为Constraint模块和Allocator模块提供基础数据支持. 在本例中, 我们把用户数据用JSON格式保存在本地.

第三, 算法的实际调用由服务层中的分配服务(AllocationService)模块实现.

最后, 在Web层实现对HTTP请求的响应, 即返回分配服务(AllocationService)计算的结果.

因此我们得到一个简单的分层架构(见下图).

算法架构

2. 构建代码框架

本项目基于Java的Spring Boot框架实现, 原因是框架帮我们提供了丰富的工程层面的工具, 例如实现HTTP接口, 日志, 线程池, 缓存等. 我们可以用IDE工具IntelliJ IDEA新建项目, 详细方法可以参考:

使用IntelliJ IDEA构建Spring Boot项目示例

按照上面的架构图, 我们把项目的文件结构按照下图组织.

+ beans  # 基础数据结构
+ configs  # 配置类
+ core  # 核心模块的实现
| + allocator  # 分配器
| + constraint  # 业务约束
| + data  # 用户数据
+ service  # 调用core中的模块, 实现功能
+ web  # 调用service实现HTTP接口

3. 定义接口

我们采用自顶向下(Top-Down)的设计方法.

3.1 Web接口

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为例, 它有两个依赖:

因此, 我们需要在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));
    }
    
}

注解说明

Remark 利用Mock工具做单元测试, 各模块的开发者可以独立工作并测试其负责的模块, 因此无需关心开发的顺序. 作者完成本例各模块单元测试之后, 所有模块联调一次通过. 虽然写单元测试时觉得有些麻烦, 但会在后期极大地降低整个系统的联调代价.

6. 性能调优

6.1 使用缓存

注意到Allocator和Constraint模块要多次调用UserData, 因此会有重复调用, 不仅浪费资源而且会降低系统的响应时间. 本项目使用Ehcache3. 当配置好缓存, 只需要使用注解(JCache)即可轻松实现缓存操作.

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)

  1. 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 -->
  1. 创建配置类

例子中设置了两个缓存:

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)
            );
        }
    }
}
  1. 添加在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>
  1. 在Spring Boot配置文件中关联ehcahce.xml (注意: 这一步不是必须的)

application.properties

spring.cache.jcache.config=classpath:ehcache.xml

多线程可以处理并发. 我们可以利用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. 打包和部署

8. 练习

试着在项目的基础上增加新的功能

上一篇下一篇

猜你喜欢

热点阅读