首页 文章 精选 留言 我的

精选列表

搜索[通用人工智能],共10000篇文章
优秀的个人博客,低调大师

Oplog4j - 基于Spring的通用操作日志生成工具

作为一个JAVA程序员, 都应该了解操作日志的需求吧? 这里介绍一个快速实现类似操作日志, 审核日志等审查功能的开源工具oplog4j. 实例 先来看一个OpLog4j生成的操作日志实例. 为了便于查看, 这里对内容做了格式化, 还添加了一些注释说明. 开发者可以根据这个实例的内容判断OpLog4j是否能满足产品的需求. //以下内容是基于一个单元测试中, com.github.djbing85.model.DefaultOpLog的真实输出 summary: update user info //操作项名称, 这里是更新用户信息 operator: system admin //这里记录了操作人, 如果你的操作人是一个ID, 也可以在想要的地方进行转换 //变更前的UserBO, JSON格式 pre: {"auditStatus":0,"balance":815146771322186439.1516528490624142744280788974720053374767303466796875,"classifyMsg":"top secret: 06dd9897-8600-49bf-a61a-ef99dae25136","createTime":1599493177359,"ignoreField":"invisible.de272fdd-f483-4a5c-b841-3b6ab4932941","phone":"911","pswd":"The quick brown fox jumps over the lazy dog","status":0,"type":"e","userId":1,"userName":"jasper.d.0e6721ab-f88a-4144-908a-e5667792fe7a"} //变更后的UserBO, JSON格式 post: {"auditStatus":1,"balance":5825067578147956587.33458620514737635875945898078498430550098419189453125,"classifyMsg":"top secret: 677b5c9d-b7f5-4ef3-9f67-d8b37391cbad","createTime":1599493177359,"ignoreField":"invisible.9a1c12c3-6ea9-4249-9176-9f13ddd464e9","phone":"911","pswd":"The quick brown fox jumps over the lazy dog","status":1,"type":"p","userId":1,"userName":"jasper.d.1454e20f-3f34-46d5-8c4e-cb20442348f8"} //变更详情, 变更详情的内容是在com.github.djbing85.aop.DefaultOpLogAOPInterceptor.getModelDiff(Class<BO>, Object, Object)中生成的, 可以通过继承类的方式定制自己想要的变更详情样式. diff: //<变更项目名称> <变更前的值> --> <变更后的值> User Name: jasper.d.6b25870e-bf31-448d-a3e2-8c19a87b915e --> jasper.d.7ea08e0e-0409-4da1-b283-e04de87a93d8 //状态的原值是0/1, 这里转换成了Enable/Disable, 详细的实现请参考后续的文档 Status: Enable --> Disable //注意这里的0, 是没有正确进行fieldMapping的情况下, 取的原值展示 Audit Status: 0 --> PENDING //对比原值-7500572336794443600.2980875307817212327421430018148384988307952880859375, 可见数字被格式化了 Balance: -7,500,572,336,794,443,600.3 --> 8,830,565,855,212,825,657.58 //操作的时间 opTime: Tue Sep 08 11:05:01 CST 2020 //类型 opType: UPDATE //BO类 modelClass: class com.github.djbing85.test.xml.model.UserBO 上面这样详细的操作变更详情, 应该就可以满足大部分对于操作日志的需求了. 这里我们总结一下操作日志的几个要素: 操作日志的要素 要素 说明 summary 方法简要描述 operator 操作人 pre 变更前BO值 post 变更后BO值 diff 差异 opTime 操作时间 opType 操作类型, 新增/编辑/删除 modelClass 操作日志bean的类型 实现流程 引入依赖 Maven Dependency 参考 Maven Center Repo, 如下引入MAVEN依赖 <dependency> <groupId>com.github.djbing85</groupId> <artifactId>oplog4j</artifactId> <version>0.0.1-RC01</version> </dependency> Gradle Groovy implementation 'com.github.djbing85:oplog4j:0.0.1-RC01' Gradle Kotlin implementation("com.github.djbing85:oplog4j:0.0.1-RC01") 配置总览 之后开发者所需要做的, 仅仅是通过一些简单配置, 就可以快速实现操作日志的功能 配置项目 说明 类 Bean注释 标记BO Model bean的属性名称, 转换规则等等 OpLogModel, OpLogField Service/Dao的切入方法 配置产生日志的方法, 该方法通常变更了对应BO的数据 OpLogJoinPoint, OpLogParam, OpLogID Spring配置 支持xml方式和springboot方式配置; 如果是xml, 仅需要最少8行配置代码 参考Spring配置配置 Handler类 方法执行完毕后, 日志如果需要写入DB, 则需要在这里实现 实现对应BO Model bean的IOpLogHandler接口 下面我们一步步地介绍如何实现 Bean注释 对于需要生成操作日志的BO类, OpLog4j要求必需有且仅有一个ID做为主键, 目前暂时不支持复合主键的类; 特别地, 对于特定的组合bean, 也可以通过巧妙的配置达到生成操作日志的功能, 但这类组合bean也需要满足这样的要求: 能够由一个ID为主键从存储介质中加载. Bean 注释的类是OpLogModel与OpLogField, 分别作用于类与属性上. OpLogModel 这个annotation 的作用域是ElementType.TYPE 属性 说明 daoBeanId Spring中定义的DAO bean ID method 如com.xxx.dao.UserBODao.getById(Long id), 则method应该取值: "getById" 通过上面两个配置, 我们就可以从Spring中获取得到一个BO(Business Object)对应DAO的getById方法. 这个方法在生成操作日志时获取"变更前对象"和"变更后对象"起到了重要的作用. OpLogField 这个annotation 的作用域是ElementType.FIELD 我们直接给出一个例子: import java.math.BigDecimal; import java.util.Date; import com.github.djbing85.annotation.OpLogField; import com.github.djbing85.annotation.OpLogModel; //BO类注释, 注意代码中一定要有对应的com.xxx.dao.UserBODao.getById(Long id)方法 @OpLogModel(daoBeanId = "userBODao", method = "getById") public class UserBO { //如果有自定义的构造方法, 那么请一定补充一个默认构造方法, 否则会报异常导致无法生成操作日志 public UserBO() {} //id = 0表示这是主键的第0个参数, 目前只支持一个属性做主键, 复合主键功能尚不支持; //fieldName是属性的名称, 如果有国际化的需求, 需要注意这里 @OpLogField(id = 0, fieldName = "User ID") private Long userId; //fieldName为""或者null时, 会直接使用fieldName = "userName" @OpLogField(fieldName = "User Name") private String userName; //DB中type的取值是p/e, 分别表示Personal/Enterprise, fieldMapping则是一个JSON, 在生成对比的变化内容时, 会把p/e转换成可读性更好的Personal/Enterprise. @OpLogField(fieldName = "User Type", fieldMapping = "{\"p\":\"Personal\", \"e\":\"Enterprise\"}") private String type; //isSensitive表示这是一个敏感字段, 需要把内容转换成**, 即审查人员也是不能看到具体内容的; @See OpLogSensitiveTypeEnum 查看更多细节 @OpLogField(fieldName = "Password", isSensitive = true) private String pswd; //日期格式化支持Date/Calendar/LocalDate/LocalTime/LocalDateTime, 注意格式化失败时会直接输出createTime.toString() @OpLogField(fieldName = "Create Time", dateFormat = "yyyy-MM-dd HH:mm:ss") private Date createTime; //这里的fieldMapping对Integer进行了转换 @OpLogField(fieldName = "Status", fieldMapping = "{0:\"Disable\", 1:\"Enable\"}") private Integer status; @OpLogField(fieldName = "Audit Status", fieldMapping = "{1:\"PENDING\", 2:\"PASS\", 3:\"REJECT\"}") private Integer auditStatus; //金额的格式化 @OpLogField(fieldName = "Balance", decimalFormat = "#,###.##") private BigDecimal balance; //标注了ignore的field不会进行内容比对. @OpLogField(ignore = true) private String ignoreField; //getter and setter ... //我们建议非性能关键的BO都写一个toString()方法, 方便在调试代码时查看BO的值 @Override public String toString() { StringBuilder builder = new StringBuilder(); builder.append("UserBO [userId="); builder.append(userId); ... builder.append("]"); return builder.toString(); } } 对应着上面的例子, 下面是更详细的配置说明 属性 说明 例值 id 默认-1:非主键. 主键上标注: [id = 0], 暂时不支持复合主键 id = 0 fieldName 属性的名称 User Name fieldMapping json字符串, 会把属性值做key, 转换成对应的value输出; 注意从map中取出的value为null时, 不会进行转换. {"0": "disabled", "1": "enabled"} ignore 默认false, 为true的field在对比时会被忽略 false isSensitive 默认false, 为true时会按maskPattern的策略对内容进行隐藏, 默认maskPattern = OpLogSensitiveTypeEnum.MASK_MIDDLE, 如内容为12345678, 则处理后的内容为12**78 false maskPattern 目前支持隐藏策略为: 隐藏前缀/后缀/中间/两边/全部, 默认隐藏全部. 详情见附表1 OpLogSensitiveTypeEnum.MASK_MIDDLE dateFormat 支持基本的日期类, 详情见附表2. 取值要与field匹配, 否则会导致日期格式化异常 yyyy-MM-dd HH:mm:ss decimalFormat 数字的格式化, 支持Double/Float/Long/Integer/BigDecimal, 为""或null时不会对数字进行格式化 #,###.## 附表1 dateFormat支持的日期类型 java.util.Date java.util.Calendar java.time.LocalDate java.time.LocalTime java.time.LocalDateTime 注意dateFormat取值要与field匹配, 避免出现日期格式化失败的情况 附表2 maskPattern支持的隐藏策略 策略 OpLogSensitiveTypeEnum.MASK_PREFIX 隐藏前缀 OpLogSensitiveTypeEnum.MASK_SUBFIX 隐藏后缀 OpLogSensitiveTypeEnum.MASK_MIDDLE 隐藏中间 OpLogSensitiveTypeEnum.MASK_2SIDES 隐藏两边 OpLogSensitiveTypeEnum.MASK_ALL 默认隐藏全部 切入方法 OpLogJoinPoint 这个annotation 的作用域是ElementType.TYPE 当前版本尚不支持重复注释同一方法 属性 说明 例值 summary 方法简要说明 如: 新增用户 operator 操作人, 使用EL表达式读取参数列表中的某个参数作为操作人, 如方法是void updatePassword(UserBO user), 当配置了operator = "user.userName"时, 操作人就会取参数中user.getUserName(), 赋值到最后生成的操作日志类DefaultOpLog.operator中 "user.userName" modelClass 指定与方法关联的BO类, 必须与OpLogID联合使用. 示例 UserBO.class useReturn 如果方法编辑并返回了操作后的BO实例, 那么应该set userReturn = true, 可以减少一次DB的读操作 默认false OpLogParam 这个annotation 的作用域是ElementType.PARAMETER 属性 说明 例值 isLoaded 为true时表示方法的pre-BO直接使用被标注的对象, 为false时pre-BO则需要按对象中配置的DAO bean与主键从DB中加载 默认false OpLogID 这个annotation 的作用域是ElementType.PARAMETER 属性 说明 例值 order 预留字段. 当前版本不支持复合主键, 请不要配置该属性 default 0 示例 //summary描述了方法的操作摘要, //operator指定了操作人, 这里会直接取参数中operator的值 //OpLogParam(isLoaded = true) 表示pre-BO可以直接使用被标注的参数UserBO bo, 从而减少一次DB读操作 @OpLogJoinPoint(summary = "update user info", operator = "operator") public UserBO updateIsLoaded(@OpLogParam(isLoaded = true)UserBO bo, String operator) { Assert.notNull(bo); Assert.notNull(bo.getUserId()); db.put(bo.getUserId(), bo); return bo; } //modelClass与@OpLogID搭配使用, 在方法执行前后, 会自动读取updateNameById前后的对象, 并生成操作日志 //注意UserBO中需要有一个id字段(名称可以不一样), 类型要与这里的Long id匹配. @OpLogJoinPoint(summary = "update user name", modelClass = UserBO.class) public void updateNameById(@OpLogID Long id, String name) { Assert.notNull(id); UserBO bo = db.get(id); Assert.notNull(bo); bo.setUserName(name); db.put(id, bo); } //useReturn=true表示可以直接使用方法返回的对象作为post-BO, 不需要重复读取DB. //注意这里可能有DB默认更新的字段无法反馈到操作日志中, 比如mysql中定义了ON UPDATE的字段, 使用时应该注意 @OpLogJoinPoint(summary = "update user balance", useReturn=true) public UserBO updateBalanceById(@OpLogID Long id, BigDecimal balance) { Assert.notNull(id); UserBO bo = db.get(id); Assert.notNull(bo); bo.setBalance(balance); db.put(id, bo); return bo; } modelClass选择机制 生产中很多代码可能在一个方法中对多个BO进行了修改, OpLog4j还不支持在一个方法中配置多个OpLogJoinPoint来生成多个BO的操作日志. 一个方法只能对一个BO产生操作日志, 运行时按以下顺序选择modelClass: 1. OpLogJoinPoint.modelClass配置的类, 通常与OpLogID搭配使用 2. OpLogJoinPoint.useReturnValue = true时尝试使用方法返回对象的class 3. OpLogParam注释参数的class 4. 参数中第一个类型是被OpLogModel注释类的class Spring配置 支持xml与springboot方式的配置 注意源代码中xml与springboot的测试代码使用了不同的BO xml方式 <!-- aop config --> <aop:aspectj-autoproxy proxy-target-class="true"/> <!-- 输出操作日志的handler, 需要实现IOpLogHandler接口 --> <bean id="userOpLogHandler" class="com.github.djbing85.test.xml.aop.handler.UserOpLogHandler"/> <!-- handler列表, 可以有多个 --> <util:list id="opLogHandlers"> <ref bean="userOpLogHandler" /> </util:list> <!-- 默认操作日志处理类. 有需要修改操作日志的diff输出时, 可以继承AbstractOpLogAOPInterceptor, 实现自己想要的输出格式 --> <bean id="defaultOpAOPInterceptor" class="com.github.djbing85.aop.DefaultOpLogAOPInterceptor"> <property name="handlers" ref="opLogHandlers" /> </bean> <!-- JSON格式的操作日志处理类. --> <!-- <bean id="jsonDiffOpAOPInterceptor" class="com.github.djbing85.aop.JsonDiffOpLogAOPInterceptor"> <property name="handlers" ref="opLogHandlers" /> </bean> --> springboot方式 //定义handler, 注意需要实现IOpLogHandler接口 @Bean public IOpLogHandler commodityOpLogHandler() { CommodityOpLogHandler h = new CommodityOpLogHandler(); return h; } //定义handler @Bean public IOpLogHandler couponOpLogHandler() { CouponOpLogHandler h = new CouponOpLogHandler(); return h; } //定义handler @Bean public IOpLogHandler orderChangeOpLogHandler() { OrderChangeOpLogHandler h = new OrderChangeOpLogHandler(); return h; } //定义handler @Bean public IOpLogHandler orderOpLogHandler() { OrderOpLogHandler h = new OrderOpLogHandler(); return h; } //定义handler list @Bean public List<IOpLogHandler> opLogHandlerList() { List<IOpLogHandler> list = new ArrayList<>(); list.add(commodityOpLogHandler()); list.add(couponOpLogHandler()); list.add(orderChangeOpLogHandler()); list.add(orderOpLogHandler()); return list; } // 定义DefaultOpLogAOPInterceptor, 注入handler list @Bean public DefaultOpLogAOPInterceptor defaultOpAOPInterceptor() { DefaultOpLogAOPInterceptor defaultOpAOPInterceptor = new DefaultOpLogAOPInterceptor(); defaultOpAOPInterceptor.setHandlers(opLogHandlerList()); return defaultOpAOPInterceptor; } //json格式的操作日志拦截器 //@Bean //public JsonDiffOpLogAOPInterceptor jsonDiffOpAOPInterceptor() { // JsonDiffOpLogAOPInterceptor jsonDiffOpAOPInterceptor = new JsonDiffOpLogAOPInterceptor(); // jsonDiffOpAOPInterceptor.setHandlers(opLogHandlerList()); // return jsonDiffOpAOPInterceptor; //} IOpLogHandler接口 IOpLogHandler是输出操作日志的地方, 通常可以在这里对操作日志做进一步的加工, 然后保存到DB中 import com.github.djbing85.aop.handler.IOpLogHandler; import com.github.djbing85.model.DefaultOpLog; import com.github.djbing85.test.springboot.model.UserOrder; public class OrderOpLogHandler implements IOpLogHandler<UserOrder> { // 指定操作日志的class @Override public Class<UserOrder> getModelClass() { return UserOrder.class; } @Override public void handleDiff(DefaultOpLog<UserOrder> log) { System.out.println("summary: " + log.getSummary()); System.out.println("operator: " + log.getOperator()); System.out.println("pre: " + log.getPre()); System.out.println("post: " + log.getPost()); System.out.println("diff: " + log.getDiff()); System.out.println("opTime: " + log.getOpTime()); System.out.println("opType: " + log.getOpType()); System.out.println("modelClass: " + log.getModelClass()); } } DefaultOpLogAOPInterceptor 这里给出一例源码中IOpLogHandler.handleDiff的输出如下: summary: Order Change operator: null pre: {"commodity":{"id":2,"img":"http://www.abc.org/path2/to/img.jpg","name":"hydrogen peroxide solution","priceBigDecimal":200,"priceDouble":200.0,"priceFloat":200.0,"priceInt":200,"priceLong":200},"coupon":{"discountDesc":"20.00%","discountInt":2000,"id":2,"name":"50% OFF","priceDouble":200.0},"order":{"commodityId":2,"couponId":2,"createdTime":1599492382991,"date":"2020-09-07","dateTime":"2020-09-07T23:26:22.991","orderId":2,"time":"23:26:22.991","totalPrice":200,"userId":2},"orderId":2} post: {"commodity":{"id":2,"img":"http://www.abc.org/path2/to/img.jpg","name":"hydrogen peroxide solution","priceBigDecimal":543,"priceDouble":543.0,"priceFloat":543.0,"priceInt":543,"priceLong":543},"coupon":{"discountDesc":"6.67%","discountInt":667,"id":2,"name":"50% OFF","priceDouble":200.0},"order":{"commodityId":2,"couponId":2,"createdTime":1599492382991,"date":"2020-09-07","dateTime":"2020-09-07T23:26:22.991","orderId":2,"time":"23:26:22.991","totalPrice":1111,"userId":2},"orderId":2} diff: Order: Total Price: 200 --> 1,111 Commodity: Price in Float: $200 --> $543 Price in Double: $200 --> $543 Price in BigDecimal: $200 --> $543 Price in Long: $200 --> $543 Price in Integer: $200 --> $543 Coupon: Discount: 20.00% --> 6.67% opTime: Tue Sep 08 11:05:01 CST 2020 opType: UPDATE modelClass: class com.github.djbing85.test.springboot.model.OrderChange JsonDiffOpLogAOPInterceptor 这里与DefaultOpLogAOPInterceptor不同的地方在于diff summary: Order Change operator: null pre: {"commodity":{"id":2,"img":"http://www.abc.org/path2/to/img.jpg","name":"hydrogen peroxide solution","priceBigDecimal":200,"priceDouble":200.0,"priceFloat":200.0,"priceInt":200,"priceLong":200},"coupon":{"discountDesc":"20.00%","discountInt":2000,"id":2,"name":"50% OFF","priceDouble":200.0},"order":{"commodityId":2,"couponId":2,"createdTime":1599621329343,"date":"2020-09-09","dateTime":"2020-09-09T11:15:29.343","orderId":3,"time":"11:15:29.343","totalPrice":500,"userId":2},"orderId":2} post: {"commodity":{"id":2,"img":"http://www.abc.org/path2/to/img.jpg","name":"hydrogen peroxide solution","priceBigDecimal":543,"priceDouble":543.0,"priceFloat":543.0,"priceInt":543,"priceLong":543},"coupon":{"discountDesc":"6.67%","discountInt":667,"id":2,"name":"50% OFF","priceDouble":200.0},"order":{"commodityId":2,"couponId":2,"createdTime":1599621329343,"date":"2020-09-09","dateTime":"2020-09-09T11:15:29.343","orderId":3,"time":"11:15:29.343","totalPrice":1111,"userId":2},"orderId":2} diff: [{"fieldName":"Order","subModelDiffList":[{"fieldName":"Total Price","from":"\"500\"","to":"\"1,111\""}]},{"fieldName":"Commodity","subModelDiffList":[{"fieldName":"Price in Float","from":"\"$200\"","to":"\"$543\""},{"fieldName":"Price in Double","from":"\"$200\"","to":"\"$543\""},{"fieldName":"Price in BigDecimal","from":"\"$200\"","to":"\"$543\""},{"fieldName":"Price in Long","from":"\"$200\"","to":"\"$543\""},{"fieldName":"Price in Integer","from":"\"$200\"","to":"\"$543\""}]},{"fieldName":"Coupon","subModelDiffList":[{"fieldName":"Discount","from":"\"20.00%\"","to":"\"6.67%\""}]}] opTime: Wed Sep 09 11:15:29 CST 2020 opType: UPDATE modelClass: class com.github.djbing85.test.springboot.model.OrderChange DefaultOpLog操作日志 如上一小节的IOpLogHandler.handleDiff输出, 就是一个DefaultOpLog, 我们来看一下它都有哪些属性 字段 说明 相关配置项 summary 方法简要描述 OpLogJoinPoint.summary operator 操作人 OpLogJoinPoint.operator pre 操作前值 OpLogJoinPoint方法针对的BO实例, 参考modelClass选择机制 post 操作后值 OpLogJoinPoint方法针对的BO实例, 参考modelClass选择机制 diff 差异 在com.github.djbing85.aop.AbstructOpLogAOPInterceptor.getModelDiff(Class<BO>, Object, Object)方法中生成; 在IOpLogHandler.handleDiff中输出 opTime 操作时间 java.util.Date实例 opType 操作类型 根据pre/post是否为null, 分为CREATE/UPDATE/DELETE三大类 modelClass 操作类 操作日志的BO类, 参考modelClass选择机制 注意事项 自定义constructor 如果BO有自定义constructor, 需要写一个默认constructor. 记录IP 有一些项目要求记录操作者的IP地址, 这一类需求也是可以满足的, 可以把IP与操作人按一定的格式写入OpLogJoinPoint.operator所标注的参数中, 在输出日志时分离两项, 分别保存即可. //service保存方法 @OpLogJoinPoint(summary = "保存订单", operator = "ext") public orderSave(UserOrder order, String ext) { //保存 ... } ... //controller某个方法 public testOp() { String operator = "Admin"; String ip = "127.0.0.1"; //order change UserOrder order = loadOrder(1L); order.setTotalPrice(new BigDecimal(1234)); //IP与操作人都写在保存方法的ext字段中 orderSave(order, operator + "@@@" + ip); } ... //IOpLogHandler.handleDiff public void handleDiff(DefaultOpLog<UserOrder> log) { String [] strArray = log.getOperator().split("@@@"); System.out.println("operator: " + strArray[0]); System.out.println("ip: " + strArray[1]); ... } 类似地, 一些其它的信息也可以通过同样的方法传递到最终的操作日志中来 组合bean生成操作日志 组合bean必需可以由唯一的一个ID加载, 且需要自己在handler中处理List类型的属性 示例如下: //use lombok @Data @OpLogModel(daoBeanId = "orderChangeService", method = "orderDetail") public class OrderChange { //唯一ID @OpLogField(id = 0, fieldName = "Order ID") private Long orderId; //组合属性如下 //注意不要添加除fieldName外的其它属性 @OpLogField(fieldName = "Order") private UserOrder order; //注意不要添加除fieldName外的其它属性 @OpLogField(fieldName = "Commodity") private Commodity commodity; //注意不要添加除fieldName外的其它属性 @OpLogField(fieldName = "Coupon") private Coupon coupon; } ////////////////////////// //OrderChangeService是OrderChange的service实现类 @Service public class OrderChangeService { ... //这个方法修改了OrderChange内部几个组合bean的属性 @OpLogJoinPoint(summary = "Order Change", useReturn = true) public OrderChange orderChange(OrderChange change) { orderDao.updateTotalPrice(change.getOrderId(), change.getOrder().getTotalPrice()); commodityDao.updatePrice(change.getCommodity().getId(), change.getCommodity().getPriceBigDecimal()); couponDao.updateDiscount(change.getCoupon().getId(), change.getCoupon().getDiscountInt()); return orderDetail(change.getOrderId()); } //按orderId返回OrderChange, 用于生成操作日志时加载pre-BO与post-BO public OrderChange orderDetail(Long orderId) { UserOrder order = orderDao.getById(orderId); Coupon coupon = couponDao.getById(order.getCouponId()); Commodity commodity = commodityDao.getById(order.getCommodityId()); OrderChange change = new OrderChange(); change.setOrderId(orderId); UserOrder order2 = new UserOrder(); BeanUtils.copyProperties(order, order2); Coupon coupon2 = new Coupon(); BeanUtils.copyProperties(coupon, coupon2); Commodity commodity2 = new Commodity(); BeanUtils.copyProperties(commodity, commodity2); change.setOrder(order2); change.setCoupon(coupon2); change.setCommodity(commodity2); return change; } } ////////////////////////// //单元测试 @Test public void orderChange() { //从DB中读取数据 OrderChange change = orderService.orderDetail(2L); //修改数据 change.getOrder().setTotalPrice(new BigDecimal(1111)); change.getCommodity().setPriceBigDecimal(new BigDecimal(543)); change.getCoupon().setDiscountInt(667); //保存数据, 完成后OpLog4j会生成操作日志 orderService.orderChange(change); } ////////////////////测试输出 summary: Order Change operator: null pre: {"commodity":{"id":2,"img":"http://www.abc.org/path2/to/img.jpg","name":"hydrogen peroxide solution","priceBigDecimal":200,"priceDouble":200.0,"priceFloat":200.0,"priceInt":200,"priceLong":200},"coupon":{"discountDesc":"20.00%","discountInt":2000,"id":2,"name":"50% OFF","priceDouble":200.0},"order":{"commodityId":2,"couponId":2,"createdTime":1599534380355,"date":"2020-09-08","dateTime":"2020-09-08T11:06:20.355","orderId":3,"time":"11:06:20.355","totalPrice":500,"userId":2},"orderId":2} post: {"commodity":{"id":2,"img":"http://www.abc.org/path2/to/img.jpg","name":"hydrogen peroxide solution","priceBigDecimal":543,"priceDouble":543.0,"priceFloat":543.0,"priceInt":543,"priceLong":543},"coupon":{"discountDesc":"6.67%","discountInt":667,"id":2,"name":"50% OFF","priceDouble":200.0},"order":{"commodityId":2,"couponId":2,"createdTime":1599534380355,"date":"2020-09-08","dateTime":"2020-09-08T11:06:20.355","orderId":3,"time":"11:06:20.355","totalPrice":1111,"userId":2},"orderId":2} diff: Order: Total Price: 500 --> 1,111 Commodity: Price in Float: $200 --> $543 Price in Double: $200 --> $543 Price in BigDecimal: $200 --> $543 Price in Long: $200 --> $543 Price in Integer: $200 --> $543 Coupon: Discount: 20.00% --> 6.67% opTime: Tue Sep 08 11:06:20 CST 2020 opType: UPDATE modelClass: class com.github.djbing85.test.springboot.model.OrderChange 在测试的输出结果中的diff部分, 我们可以看到, orderChange.order.totalPrice等等几个内部bean的变更被如实地记录了下来. Collection属性 当前版本的OpLog4j尚不支持Collection属性的变更, 有这部分需求的开发者需要自行比较pre/post中的list对象, 参考IOpLogHandler接口 国际化 OpLog4j支持对fieldName和summary进行国际化 需要在输出的diff中实现国际化的开发者, 需要额外进行以下代码配置: OpLog4j的配置中使用JsonDiffOpLogAOPInterceptor 配置国际化拦截器 源码里国际化工具类使用了OpLog4jMessageUtils, 而通常你应该使用自己的国际化工具类实现同样的功能. @Configuration public class I18nConf { /** * default LocaleResolver */ @Bean public LocaleResolver localeResolver() { SessionLocaleResolver localeResolver = new SessionLocaleResolver(); localeResolver.setDefaultLocale(Locale.US); return localeResolver; } /** * localeInterceptor, "lang" is the parameter name */ @Bean public WebMvcConfigurer localeInterceptor() { return new WebMvcConfigurer() { @Override public void addInterceptors(InterceptorRegistry registry) { LocaleChangeInterceptor localeInterceptor = new LocaleChangeInterceptor(); localeInterceptor.setParamName("lang"); registry.addInterceptor(localeInterceptor); } }; } //如果你已经有一个ResourceBundleMessageSource, 那么在OpLog4jMessageUtils中直接使用即可, 不需要重复定义这个bean @Bean public ResourceBundleMessageSource messageSource() { Locale.setDefault(Locale.CHINESE); ResourceBundleMessageSource source = new ResourceBundleMessageSource(); source.setBasenames("i18n/messages/messages");// path and name of the resource bundle source.setUseCodeAsDefaultMessage(true); source.setDefaultEncoding("UTF-8"); return source; } //定义一个国际化工具类 @Bean public OpLog4jMessageUtils opLog4jMessageUtils() { return new OpLog4jMessageUtils(messageSource()); } } 给每个model配置对应语言的messages.properties 如源码中: /oplog4j/src/test/resources/i18n/messages/messages_zh_CN.properties coupon.diff=差异 coupon.discount=折扣 total.price=总价 /oplog4j/src/test/resources/i18n/messages/messages_en_US.properties coupon.diff=DIff coupon.discount=Discount total.price=Total Price 配置OpLogField中的fieldName 配置成messages.properties中对应的国际化配置项, 如: //原来的配置 //@OpLogField(fieldName = "Total Price", decimalFormat = "#,###.##") //国际化的fieldName配置 @OpLogField(fieldName = "total.price", decimalFormat = "#,###.##") private BigDecimal totalPrice; 运行WEB 如在Eclipse中, 打开源码的com.github.djbing85.test.springboot.OpLog4jApplication, 右键Run As --> 1 Java Application 访问http://127.0.0.1:8080/i18nTest?lang=en_US {"fieldName":"Coupon","subModelDiffList":[{"fieldName":"Discount","from":"\"20.00%\"","to":"\"6.67%\""}]} 访问<http://127.0.0.1:8080/i18nTest?lang=zh_CN {"fieldName":"Coupon","subModelDiffList":[{"fieldName":"折扣","from":"\"20.00%\"","to":"\"6.67%\""}]} 前端再根据需求显示即可.

优秀的个人博客,低调大师

微服务的断路器实现图解Golang通用版

断路器背景 微服务连锁故障场景 在分布式环境中,各个微服务相互调用,当某些情况下,比如后端中间件服务故障、第三方服务中断导致某个服务无限期不可用,短时间无法恢复,则可能会导致连锁故障,最终影响压垮整个业务集群 断路器与重试 断路器模式不同于重试模式,重试模式是使应用程序可以重试操作以期望它会成功,而断路器模式是防止应用程序执行一个可能失败的操作,减少执行可能失败操作的CPU、内存、线程等资源的浪费,从而保证服务的整体可用 断路器设计解析 基于代理模式的断路器 断路器相当于一个请求操作执行的代理,托管请求操作的执行 实现原理流程: 拦截服务执行的请求,通过当前状态决定是否直接返回,如果否则执行后续操作 尝试执行操作,并获取返回结果 根据返回结果和当前统计信息,决定当前断路器的状态,修改状态 返回执行结果 断路器状态机 断路器状态机实现上有三种状态:Closed(断路器关闭)、Open(开放)、HalfOpen(半开放) 状态 说明 备注 Closed 关闭 断路器关闭正常执行操作 Open 打开 断路器开放,所有请求直接返回错误,不执行任何请求 HalfOpen 半开放 允许有限数量的请求通过,如果执行成功,恢复到关闭状态,如果仍然失败,则恢复到开放,然后重新启动超时定时器 #断路器实现 实现原理图解 断路器实现实现主要分为三部分:状态统计、状态转移、请求执行 状态统计:统计已经执行的请求的成功失败的数量,以确定是否需要进行状态转移 状态转移:根据当前统计信息和当前状态来进行目标状态的确定及转移操作 请求执行:代理前端任务的执行,如果当前状态不需要进行尝试执行,就直接返回错误,避免资源浪费 Golang里面已经有开源的实现,https://github.com/sony/gobreaker/blob/, 接下来救市剖析它的实现 状态统计-计数器Counts Counts就是一个计数器,记录当前请求成功和失败的数量 type Counts struct { Requests uint32 // 请求数 TotalSuccesses uint32 // 成功 TotalFailures uint32 // 失败 ConsecutiveSuccesses uint32 // 连续成功 ConsecutiveFailures uint32 // 连续失败 } 计数器完成对应请求状态的次数,为后续状态转移提供数据, Counts提供了onRequest、onSuccess、onFailure、clear几个辅助接口用于实现对应请求状态的操作,感兴趣可以看下 状态机- CircuitBreaker type CircuitBreaker struct { name string // maxRequests限制half-open状态下最大的请求数,避免海量请求将在恢复过程中的服务再次失败 maxRequests uint32 // interval用于在closed状态下,断路器多久清除一次Counts信息,如果设置为0则在closed状态下不会清除Counts interval time.Duration // timeout进入open状态下,多长时间切换到half-open状态,默认60s timeout time.Duration // readyToTrip熔断条件,当执行失败后,会根据readyToTrip决定是否进入Open状态 readyToTrip func(counts Counts) bool // onStateChange断路器状态变更回调函数 onStateChange func(name string, from State, to State) mutex sync.Mutex //. state 断路器状态 state State // generation 是一个递增值,相当于当前断路器状态切换的次数, 为了避免状态切换后,未完成请求对新状态的统计的影响,如果发现一个请求的generation同当前的generation不同,则不会进行统计计数 generation uint64 // Counts 统计 counts Counts // expiry 超时过期用于open状态到half-open状态的切换,当超时后,会从open状态切换到half-open状态 expiry time.Time } 核心流程 CircuitBreaker.Execute 请求执行,对外开放的请求执行接口 func (cb *CircuitBreaker) Execute(req func() (interface{}, error)) (interface{}, error) { // 执行请求钩子,会根据当前状态,来返回当前的generation和err(如果位于open和half-open则不为nil), 通过err来进行判断是否直接返回 generation, err := cb.beforeRequest() if err != nil { return nil, err } // 捕获panic,避免应用函数错误造成断路器panic defer func() { e := recover() if e != nil { cb.afterRequest(generation, false) panic(e) } }() // 执行请求 result, err := req() // 根据结果来进行对应状态的统计, 同时传递generation cb.afterRequest(generation, err == nil) return result, err } CircuitBreaker.beforeRequest func (cb *CircuitBreaker) beforeRequest() (uint64, error) { cb.mutex.Lock() defer cb.mutex.Unlock() // 获取当前的状态 now := time.Now() state, generation := cb.currentState(now) // open和half-open状态则直接返回 if state == StateOpen { return generation, ErrOpenState } else if state == StateHalfOpen && cb.counts.Requests >= cb.maxRequests { // 避免海量请求对处于恢复服务的影响,这里有一个限流的操作,避免请求数超过最大请求数 return generation, ErrTooManyRequests } // 统计状态 cb.counts.onRequest() return generation, nil } CircuitBreaker.afterRequest func (cb *CircuitBreaker) afterRequest(before uint64, success bool) { cb.mutex.Lock() defer cb.mutex.Unlock() // 重新获取状态 now := time.Now() state, generation := cb.currentState(now) // 如果前后状态不一致,则不计数 if generation != before { return } // 根据状态计数 if success { cb.onSuccess(state, now) } else { cb.onFailure(state, now) } } CircuitBreaker.currentState func (cb *CircuitBreaker) currentState(now time.Time) (State, uint64) { switch cb.state { case StateClosed: // 如果当前当前是closed状态,并且有设置expiry,则递增Generation到新一轮统计计数 if !cb.expiry.IsZero() && cb.expiry.Before(now) { cb.toNewGeneration(now) } case StateOpen: // 如果是Open状态,并且超时,则尝试到半打开状态 if cb.expiry.Before(now) { cb.setState(StateHalfOpen, now) } } return cb.state, cb.generation } CircuitBreaker.toNewgeneration func (cb *CircuitBreaker) toNewGeneration(now time.Time) { // 递增generation, 清除状态 cb.generation++ cb.counts.clear() // 设置超时时间 var zero time.Time switch cb.state { case StateClosed: if cb.interval == 0 { cb.expiry = zero } else { cb.expiry = now.Add(cb.interval) } case StateOpen: cb.expiry = now.Add(cb.timeout) default: // StateHalfOpen cb.expiry = zero } } 总结 断路器黄金链路 beforeRequest :完成当前请求是否可以执行请求,状态超时切换,同时返回当前的genenration req: 执行请求 afterRequest: 完成请求状态统计,决定状态切换 断路器的优缺点 断路器比较适合针对远程服务或者第三方服务的调用,如果该操作极有可能会失败,则断路器可以尽可能的减小失败对应用的影响,避免资源浪费 但缺点也显而易见,断路器本身相当于一层代理,在应用程序执行进行统计和控制,本身就有一定的资源消耗,同时内部基于synx.Mutex锁来实现,高并发下肯定会有锁争用问题,可能需要根据业务来使用多个断路器,来分散这种锁争用,同时应该避免在断路器req函数内,去执行重试和过长时间的超时等待,因为断路器核心是快速失败 更多文章可以访问http://www.sreguide.com/

优秀的个人博客,低调大师

利用Vue构造器创建Form组件的通用解决方法

在前端平常的业务中,无论是官网、展示页还是后台运营系统都离不开表单,它承载了大部分的数据采集工作。所以如何更好地实现它,是平常工作中的一个重要问题。 在应用Vue框架去开发业务时,会将页面上每个独立的可视/可交互区域拆分为一个组件,再通过多个组件的自由组合来组成新的页面。例如 <template> <header></header> ... <content></content> ... <footer></footer> </template> 当用户的某个行为触发表单时(例如注册、建立内容等),期望在页面中弹出一个From组件。通常的做法是在template中填入一个<form>组件用于开发,并通过控制data中的UI.isOpen来对其display进行控制,例如在当前<template>组件内开发 <template> <header></header> ... <content></content> ... <footer></footer> ... <register-form v-if="UI.isOpen"> <form-item></form-item> ... <submit-button></submit-button> </register-form> </template> Form组件与其父组件之间可以通过prop以及$emit方便通信。但是也会有以下几个缺陷: 当前组件的data必须要有UI.isOpen来控制表单,如果存在多个表单时,就会有大量的状态来维护表单的开关; 如果表单多次弹出时,可能需要对表单的data进行重置; 与组件化思想相违背,表单不属于当前页面,它只是由于用户行为触发的结果。 为了解决以上缺陷,并且还能具备方便通信的优势,本文选择用Vue.extend将原有<form>组件转化为method function,并维护在当前组件的method中,当用户触发时,在页面中挂载,关闭时自动注销。 实例 APP组件 <template> <div id="app"> <el-button type="primary" icon="el-icon-edit-outline" @click="handleClick" >注册</el-button> </div> </template> <script> import register from './components/register' import { transform } from './transform' export default { name: 'App', methods: { register: transform(register), handleClick () { this.register({ propsData: { name: '皮鞋' }, done: name => alert(`${name}牛B`) })//欢迎加入前端全栈开发交流圈一起吹水聊天学习交流:864305860 } } } </script> 当<el-button>的点击事件触发时,调用register方法,将表单组件挂载在页面中。 Form组件 <template> <div class="mock" v-if="isVisible"> <div class="form-wrapper"> <i class="el-icon-close close-btn" @click.stop="close"></i> ...<header /> ...<content /> <div class="footer"> <el-button type="primary" @click="handleClick" >确定</el-button> <el-button type="primary" @click="handleClick" >取消</el-button> </div> </div> </div> </template> <script> export default { porps: { ... }, data () { return { isVisible: true } }, watch: { isVisible (newValue) { if (!newValue) { this.destroyElement() } } }, methods: { handleClick ({ type }) { const handler = { close: () => this.close() }//欢迎加入前端全栈开发交流圈一起吹水聊天学习交流:864305860 }, destroyElement () { this.$destroy() }, close () { this.isVisible = false } }, mounted () { document.body.appendChild(this.$el) }, destroyed () { this.$el.parentNode.removeChild(this.$el) }//欢迎加入前端全栈开发交流圈一起学习交流:864305860 } </script> 在APP组件内并未维护<form>组件的状态,其打开或关闭只维护在自身的data中。 原理 上述代码中,最为关键的一步就是transform函数,它将原有的`从single-file components转化为了method function,其原理如下 const transform = (component) => { const _constructor = Vue.extend(component) return function (options = {}) { const { propsData } = options let instance = new _constructor({ propsData }).$mount(document.createElement('div')) return instance }//欢迎加入前端全栈开发交流圈一起学习交流:864305860 } 首先利用Vue.extend(options)创建一个<Form/>组件的子类 const _constructor = Vue.extend(component) 然后return function,它的功能是: 将<form />组件转化为method 在method调用时,将组件实例化并传递propsData const { propsData } = options let instance = new _constructor({ propsData }).$mount(document.createElement('div')) 为了能够控制实例化后的组件,选择instance返回。 当组件实例化时,它只是挂载到document.createElement('div')上,但是并没有挂载到页面上,所以需要将其appendChild到页面中。为了更好的语义化,选择在组件的生命周期中完成它在页面中的挂载。实例化时,会触发组件mounted生命周期,所以当其触发时可以挂载在document.body中,具体如下 mounted () { document.body.appendChild(this.$el) } 有了挂载,就必须要有注销。对应的生命周期应该是destroyed,所以 method: { destroyElement () { this.$destroy() } //欢迎加入前端全栈开发交流圈一起学习交流:864305860 }, destroyed () { this.$el.parentNode.removeChild(this.$el) } 组件注销的时间与它在页面中显示息息相关,当<form />在页面中不可见时候,需要注销它 method: { destroyElement () { this.$destroy() } //欢迎加入前端全栈开发交流圈一起学习交流:864305860 }, destroyed () { this.$el.parentNode.removeChild(this.$el) } 一般Form组件有两个功能: done:代表用户确认; cancel:代表用户取消; 当done或cancel触发时,APP组件内可能会有相应的变化,所以在组件实例化之后,利用$on去监听对应的done事件以及cancel事件。 done && inlineListen({ method: 'done', options, instance })//欢迎加入前端全栈开发交流圈一起吹水聊天学习交流:864305860 cancel && inlineListen({ method: 'cancel', options, instance }) 其中inlineListen函数可以方便后续添加其他的event,其代码为 const inlineListen = ({ method, options, instance }) => { let listener = `on${method}` instance[listener] = options[method] instance.$on(method, function (data) { this[listener](data) }) } 也可以将上述方案封装成Promise形式,如下 export const transform = (component) => { const _constructor = Vue.extend(component) return function (options = {}) { const { propsData } = options return new Promise((resolve, reject) => { let instance = new _constructor({ propsData }).$mount(document.createElement('div')) instance.$on('done', data => resolve(data)) })//欢迎加入前端全栈开发交流圈一起学习交流:864305860 } }//帮助突破技术瓶颈,提升思维能力 使用 可以将上述属于<Form/>公有的data以及method独立出来,再通过mixins引入到每个表单内,例如 export default { data() { return { visible: true } }, watch: { visible(newValue) { if (!newValue) { this.destroyElement() } } }, mounted() { document.body.appendChild(this.$el) }, destroyed() { this.$el.parentNode.removeChild(this.$el) }, methods: { destroyElement() { this.$destroy() }, close() { this.visible = false }//欢迎加入前端全栈开发交流圈一起学习交流:864305860 }//面向1-3年前端人员 }//帮助突破技术瓶颈,提升思维能力 再通过mixins混入。 <script> import popupWin from '../mixins/popup-win' export default { mixins: [popupWin], data () { return { input: '', gender: 1 } }, methods: { handleClick ({ type }) { const handler = { close: () => this.close(), confirm: () => { const { input } = this this.$emit('done', input) }//欢迎加入前端全栈开发交流圈一起学习交流:864305860 } }//面向1-3年前端人员 } }//帮助突破技术瓶颈,提升思维能力 </script> 调用时,只需 export default { name: 'App', methods: { register: transform(register), handleClick () { this.register({ propsData: { ... }, // done: data => function done () { // 外部关闭 this.close() }//欢迎加入前端全栈开发交流圈一起学习交流:864305860 }) }//面向1-3年前端人员 } }//帮助突破技术瓶颈,提升思维能力 PS:如果业务场景需要,在外部控制表单的关闭时,只需要改变done function的context,也就是this指针指向<Form/>。 总结 通过上述的transform函数,将原有的注入式组件转化为了命令式,简化了页面状态的维护,在通过mixins混入公有data以及method,简化了表单组件开发。上述方法也可用于开发toast、alert、confirm等组件,只需要将 Vue.prototype.method = transform(Toast-Component) 结语 感谢您的观看,如有不足之处,欢迎批评指正。

优秀的个人博客,低调大师

创建IOS应用程序通用下的Setting以及读取方式

在iphone和ipad中,有一个东西大家一定很熟悉,那个东西就是Settings。 这次要学习的东西说白了很简单,就是学习如何在Settings中对一个app的某些属性进行设置,反过来,在app中更改了一些属性值,也会反应到Settings中,这个功能很常用,实现起来也相对简单,但是内容还是比较多的。 首先还是对Settings进行一个简单的说明,虽然我们经常打开Settings,但是很少对Settings进行过仔细的研究,不过作为一名ios的开发人员,有这个必要对Settings进行一番探索,看看Settings里面到底包含了哪些东西,这些东西又是什么。 首先我们打开Settings,可以看到Settings的主界面(画面截取自iphone模拟器) 可以看到,Settings的主界面是一个Table View,Style的属性是Grouped。除了常见的General、Airplane Mode、Wi-Fi、Notifications等(后面3个未在截图中出现,打开你的iphone,Settings里面会有这些选项),iphone为每个有需要的app创建了一个cell,例如Twitter、Facebook等。点击Twitter,即可以看到该app所包含的一些设置项。 Settings中的设置项是有限制的,一共可以包含以下4类:Text Field,Switch,Slider, TableView的checklist。 大家应该看出,Settings其实是一个Navigation Controller,View与View之间是继承关系。 在说一下iOS中是以什么方式来操作Settings的,这个东西叫做NSUserDefaults,它可以方便的在app与Settings进行交互,保存更新值。 好了,简单的介绍就到这里,下面我们开始这次的例子,作完这个例子,相信会对Settings有更深的了解。 1)创建一个工程,左边选择Application,右边选择Utility Application,点击Next按钮 在Product Name中输入AppSettings,然后选中Use Storyboards,点击Next按钮 点击Create按钮完成创建 观察一下程序为我们自动创建的工程项目文件,里面有一个很常见的BIDAppDelegate,另外我们刚才选中的Use Storyboard,因此有一个MainStoryboard.storyboard存在,除了这些,另外还有4个文件,分别是BIDMainViewController和BIDFlipsideViewController,这个是Utility Application模板自动为我们创建的。 由于这是我们第一次使用Utility Application,因此在这里做一些简单的介绍,Utility Application会自动帮我们创建2个view,第一个view叫做main view,第二个view叫做flipside view,在main view上面有一个information button,点击这个information button就会切换到flipside view。在flipside view的navigator bar上有一个Done按钮,点击这个Done按钮,就会切换回main view。在Project navigator中选中MainStoryboard.storyboard,就会看见2这个view,而这2个view就分别对应着BIDMainViewController和BIDFlipsideViewController。 好了,下面我们需要添加一个Settings Bundle,如果你的一个app需要在Settings中进行设置,那么在你的项目中一定要加Settings Bundle,这个东西的作用就是当你把你的app安装到iphone上后,它会自动在Settings中添加与这个项目相关的一个条目(cell)用于设置。 2)添加Settings Bundle 选择File>New>File...,在左边的选择Resource,右边选择Settings Bundle,点击Next按钮 保留默认的名字,点击Create按钮完成添加 展开Settings.bundle,有2个默认项,一个是en.lproj文件夹(这个暂时忽略,用户本地化程序的,现在无需理会),另一个是Root.plist,从后缀名我们可以断定,Settings中的项是基于Property list,也就是一个xml文件。 选中Property list,在editor pane中会显示如下内容 我们改变一下Root.list的显示方式,在editor pane的任何地方点击鼠标右键,然后在弹出的菜单中选择“Show Raw Keys/Values” 然后Root.plist会显示如下样子 最直观的发现是2个Key的名字变了,“Preference Items”变成了“PreferenceSpecifiers”,“Strings Filename”变成了“StringsTable”,其实内容并没有改变,只是显示的方式不同,这个看个人喜好吧,前者名字比较通俗移动,后者名字更贴近实现情况。 另外,我们不必过多关注StringsTable,这个值是用在localization的时候的,因此在这里你可以直接删了它。 3)添加Settings中的控件添加Text Field 接着我们展开PerferenceSpecifiers,里面有4个默认的Item,这些都是系统帮我们生成的,但是这些并不是我们这个例子想要的,所以,我们删除Item 1、Item 2、Item 3,只保留Item 0。 (上图中我把StringsTable也删了) 接着我们展开Item 0,看看里面有些什么的东西 里面有一个Title和Type键值对(也就是Dictionary),我们把注意力集中到Type,它的值是PSGroupSpecifier,说明这个item是一个group类型,在这个item之后的所有同级item,都是属于这个group的,直到下一个group类型出现。在PreferenceSpecifiers下,必须要有至少一个group存在。Title用于设置这个group的名称,这个属性是可以省略的。 在观察一下Item 0的显示方式:Item 0 (Group - Group),括号中第一个Group是只这个item的类型为PSGroupSpecifier,第二个Group是这个item的Title值。我们修改一下Title的值,设置其为“General Info” 设置完成后,你会发现Item 0的显示变成了:Item 0 (Group - General Info) PSGroupSpecifier并不是一个实际可以操作的类型,它的作用仅仅是把几个相关的属性包含在一起,形成一个table view中的group,下面我们添加可操作的节点。 首先将Item 0合拢,然后鼠标选中Item 0的整行,单击键盘上的return键,一个新的Item(Item 1)出现在Item 0的下方,并有一个列表弹出,让你选择这个Item的类型 在这里我们选择Text Field,创建完Item 1后,展开Item 1 可以看到一共有3个键值对,Type的类型为PSTextFieldSpecifier,另外2个分别是Title和Key,将Title赋值为“Username”,将Key赋值为“username”(注意大小写)。这里的Key是用于保存和获取Item 1的值的,每一个Item你可以理解为是一个Dictionary,一个Dictionary就是一个键值对,那么这个Key的作用就是获取或者保存指定的值。(Item 0中并没有Key,这个是因为Item 0的类型是PSGroupSpecifier,没有需要保存的值) 在这篇文章刚刚开始的时候,我们提到过NSUserDefaultes,NSUserDefaultes就是使用这个key来获得item的值的,也是用这个key来保存更新值的。 最后我们为Item 1再添加2个属性,选中Item 1中最后一行(Key所在的行),然后按下键盘上的return,一个新的行出现在Key的下面,另外有一个list让你选中Key的名字,这里我们选择“AutocapitalizationType”,然后设置值为“None”。同样的方法再添加一次,这次选择“AutocorrectionType”,然后设置值为“No” “AutocapitalizationType”的意思是是否自动完成输入,也就是说你输入一个单词的前几个字,系统会出现一个列表,列表里面会有相关的词汇,你只有直接选中,就可以完成输入了,这里我们设置为“None”,无需自动完成。 “AutocorrectionType”的意思是自动纠正拼写,就是系统帮你建成你输入的单词是否有拼写错误,我们同样把这个功能关掉了。 好了,保存一下Root.plist。 到此为止,我们可以试着编译运行程序了,为了使程序能够在Settings中突出显示(好找一点),我们为程序添加一个图标,先下载一个图标icon,然后在Project navigator中选中根节点AppSettings,然后在左边选择TARGETS下的AppSettings,打开Summary tab,展开iPhone / iPod Deployment Info,找到App Icons 将icon拖入到左边的图标中(右边的是视网膜屏用的图标,我们这里没有提供这个图标,所以就空着吧) 好了,编译运行一下程序,程序启动后,按Home键回到桌面,然后进入Settings,在Settings主界面的最下面,可以找到我们创建的AppSettings程序图标 点击该图标,就会进入这个程序的设置 上图中我们可以看到在Root.plist中我们设置的项还原出来的结果,首先是一个Group,Group的Title为General Info,然后在Group中是一个TextField的项,它的Title是Username。 OK,到此位置,你应该对Settings有所了解,知道里面的项是怎么产生的,每个项的属性的作用等等。下面我们接着添加更多的不同类型的项,全面的对Settings进行操作。 3)添加其他的项添加Secure Text Field 先将Item 1闭合,然后选中它,按键盘上的command+C,command+V,复制粘贴一份Item,一个新的Item 2会出现在Item 1的下面 展开Item 2,并将其下的属性设置为下面的样子 如上图所示,Item 2是用于接收密码的,它的Type类型还是为PSTextFieldSpecifier,将它的Title设为Password,Key为password,这里多了一个新的属性叫做IsSecure,当将它的值设为YES时,那么在文本框中内容就会以密码的方式显示。(你可以编译运行一下,看看是不是这个密码框的效果) 添加Multivalue Field Multivalue Field会产生一个带箭头的cell,点击该行后,会跳转到下一个table view,下一个table view中会包含多个选项,用户在多个选项中选取其中的一个,然后返回到前一个view,该cell中的内容就是用户选取的内容。 我们合拢Item 2,然后选中Item 2,按return键新建Item 3,选择Item 3的类型为Multi Value 展开Item 3,可以看到,Type的类型为PSMultiValueSpecifier。将Title设置为Protocol,Key设置为protocol。 ok,下面我们会添加一组Titles和Values,Titles保存每一个选项的显示值,Values中保存每一个选项的id,他们是一一对应的。保持Item 3展开的形态,然后选中Item 3,按return键(在Item展开的形态下选中并按retuan键,是添加当前项的子项;在Item闭合的形态下选中帮按return键,是添加同级系项),会新建一个Item 3的子项并出现一个下拉框选中子项的类型,在这里我们选择Titles。 重复上面的动作,再创建一个Item 3的子项,并选择Values。 选中Titles,然后点击加号,添加一个子项,赋值为HTTP 重复这个动作,添加以下值SMTP,NNTP,IMAP,POP3 使用同样的方法,为Values添加项,添加完后的样子如下 Values和Titles不同之处在于一个是小写一个是大写,当然,如果你全部使用大写或者小写也是没有问题的,看个人喜好了。 编译运行一个程序 在Passwrod中输入密码,显示的是一个一个的小圆点。Protocol项的右边多了一个箭头,点击该项,跳转到下一个view 这里就是我们刚才添加的Titles的内容,我们随意选中一个 选中SMTP后的状态,然后我们点击左上角的AppSettings按钮返回上一级view 刚才选中的SMTP就显示在Protocol项的右边。 添加Toggle Switch Settings ToggleSwitch很简单,就是一个switch,可以选择打开或者关闭,这个类型的目的是设置一个bool值。 合拢Item 3,然后选中,按return键,添加一个Item 4,并设置Item 4的类型为Toggle Switch,展开Item 4,设置Title为“Warp Drive”,Key为“warp”,将DefaultValue的值改为YES 好了,编译运行一下,一个Switch出现在Protocol的下面 添加Slider Setting Slider我们已经熟知,在Settings中,一个Slider可以在其2端各放置一副图片(但我发现在iphone中放置图片的例子不多),Slider本身没有带文字说明,我们也不可以在Settings中放置一个label告知用户这个slider的作用是什么,因此这里的解决方案是添加一个新的Group,然后为Group添加文字说明,告知用户slider的作用。 合拢Item 4,并选中,按return键,添加Item 5,选择Item 5的类型为Group,并设置Title值为“Warp Factor” 合拢Item 5,并选中,按return键,添加Item 6,选择Item 6的类型为Slider,根据下图设置Item 6的属性 编译运行一下程序,效果如下 刚才我们说了,Slider的两端可以各添加一个图片(图片的大小为21 * 21 pixel),我们现在就来添加,先下载这里的图片 为slider添加image的方法有些特殊,我们并不是直接将图片拖到project navigator中,然后放到slider中,Settings并没有为我们提供这样的方法。我们使用别的方法添加,首先在Project navigator中鼠标右击Settings.bundle,选择Show in Finder 在Finder中右击Settings.bundle,选择“显示包内容”(Show Package Contents) 然后将2张图片复制进包里面,然后在Project navigator中也能够看见这2张图片了 接着打开Root.plist,在Item 6中添加2项Max Value Image Filename和Min Value Image Filename,为Max赋值rabbit,为Min赋值turtle 再次编译运行,2张图片出现在slider的左右2边。 添加一个Child Settings View 这个意思就是单击table view上的一个cell,跳转到另一个Settings view。经过上面的一些讲解,我们实际上都是在对plist进行操作,由此可以推断出,所有的Settings都是以一个一个的plist文件,因此如果要跳转到另一个Settings view,那必须包含另一个plist,根据这个思路,我们进行下面的操作。 我们再创建一个Group,创建在Item 6的下面,并命名为Title 然后我们在Item 7的下面添加Item 8,按照之前的方式,现在我们应该设置Item 8的类型了,但是在默认的下拉框中并没有我们需要的类型(Child Pane),不急,我们展开Item 8(在这里确保你选择了Show Raw Keys/Values),然后点击其Type行的Value列最后边的按钮,会出现一个下拉框 在下拉框中选择PSChildPaneSpecifier,这样Item 8的类型就是一个Child Pane了。 接着设置Title为“More Settings”,Key空着,因为这是一个起到导航作用的Item,我们无需得到它的值,也就不需要它的Key了。 再接着我们选中最后一行Key,按return键添加一个,在下拉菜单中选择File 我们需要关联一个plist文件,这样就可以导航到另一个Settings view了,下载这里的More.plist,还记得刚才我们是如何为slider添加2个图片的吗?在Project navigator中鼠标右击Settings.bundle,然后选择Show in Finder,在Finder中鼠标右击Setting.bundle,选择Show Package Contents,将More.plist复制进去,这样在Project navigator中就出现More.plist了。 由于More.plist是现成帮我们做好了,因为我们不需对其进行任何操作,直接用就可以了。编译运行程序 Settings view的底部多了我们刚才添加的More Settings,点击More Settings,跳转到More.plist的Settings view 可以看到在More中前4个都是Text Field,最后一个是Mulitvalue Field,之后大家随便点吧,反正也就这些东西了。 4)总结 到此为止,所有可以在Settings中添加的控件都已经介绍了,总类不是很多,一共6种,而对于Settings的操作,也就是对一个plist文件的操作,iOS系统会自动将plist中的内容反应到Settings上去,我们只需针对plist进行操作,就可以很简单的完成Settings view的设置。在下一篇中,我们将把Settings中的值和真正的app程序连接起来,在Settings中设置值后,在app中会反应出来,在app中对一个值进行更改,在Settings中的这个值同样会被更新,连接这两个东西的桥梁就是之前提到的NSUserDefaults,我们在下一篇中进行详细的介绍,谢谢! 本文转蓬莱仙羽51CTO博客,原文链接:http://blog.51cto.com/dingxiaowei/1366408,如需转载请自行联系原作者

优秀的个人博客,低调大师

OpenStack 通用设计思路 - 每天5分钟玩转 OpenStack(25)

API 前端服务 每个 OpenStack 组件可能包含若干子服务,其中必定有一个 API 服务负责接收客户请求。 以 Nova 为例,nova-api 作为 Nova 组件对外的唯一窗口,向客户暴露 Nova 能够提供的功能。 当客户需要执行虚机相关的操作,能且只能向 nova-api 发送 REST 请求。 这里的客户包括终端用户、命令行和 OpenStack 其他组件。 设计 API 前端服务的好处在于: 1. 对外提供统一接口,隐藏实现细节 2. API 提供 REST 标准调用服务,便于与第三方系统集成 3. 可以通过运行多个 API 服务实例轻松实现 API 的高可用,比如运行多个 nova-api 进程 Scheduler 调度服务 对于某项操作,如果有多个实体都能够完成任务,那么通常会有一个 scheduler 负责从这些实体中挑选出一个最合适的来执行操作。 在前面的例子中,Nova 有多个计算节点。 当需要创建虚机时,nova-scheduler 会根据计算节点当时的资源使用情况选择一个最合适的计算节点来运行虚机。 调度服务就好比是一个开发团队中的项目经理,当接到新的开发任务时,项目经理会评估任务的难度,考察团队成员目前的工作负荷和技能水平,然后将任务分配给最合适的开发人员。 除了 Nova,块服务组件 Cinder 也有 scheduler 子服务,后面我们会详细讨论。 Worker 工作服务 调度服务只管分配任务,真正执行任务的是 Worker 工作服务。 在 Nova 中,这个 Worker 就是 nova-compute 了。 将 Scheduler 和 Worker 从职能上进行划分使得 OpenStack 非常容易扩展: 当计算资源不够了无法创建虚机时,可以增加计算节点(增加 Worker) 当客户的请求量太大调度不过来时,可以增加 Scheduler Driver 框架 OpenStack 作为开放的 Infrastracture as a Service 云操作系统,支持业界各种优秀的技术。 这些技术可能是开源免费的,也可能是商业收费的。 这种开放的架构使得 OpenStack 能够在技术上保持先进性,具有很强的竞争力,同时又不会造成厂商锁定(Lock-in)。 那 OpenStack 的这种开放性体现在哪里呢? 一个重要的方面就是采用基于 Driver 的框架。 以 Nova 为例,OpenStack 的计算节点支持多种 Hypervisor。 包括 KVM, Hyper-V, VMWare, Xen, Docker, LXC 等。 Nova-compute 为这些 Hypervisor 定义了统一的接口,hypervisor 只需要实现这些接口,就可以 driver 的形式即插即用到 OpenStack 中。 下面是 nova driver 的架构示意图 在 nova-compute 的配置文件 /etc/nova/nova.conf 中由 compute_driver 配置项指定该计算节点使用哪种 Hypervisor 的 driver 在我们的环境中因为是 KVM,所以配置的是 Libvirt 的 driver。 不知大家是否还记得我们在学习 Glance 时谈到: OpenStack 支持多种 backend 来存放 image。 可以是本地文件系统,Cinder,Ceph,Swift 等。 其实这也是一个 driver 架构。 只要符合 Glance 定义的规范,新的存储方式可以很方便的加入到 backend 支持列表中。 再后面 Cinder 和 Neutron 中我们还会看到 driver 框架的应用。 Messaging 服务 在前面创建虚机的流程示意图中,我们看到 nova-* 子服务之间的调用严重依赖 Messaging。 Messaging 是 nova-* 子服务交互的中枢。 以前没接触过分布式系统的同学可能会不太理解为什么不让 API 直接调用Scheduler,或是让Scheuler 直接调用 Compute,而是非要通过 Messaging 进行中转。 这里做一些解释。 程序之间的调用通常分两种:同步调用和异步调用。 同步调用 API 直接调用 Scheduler 的接口就是同步调用。 其特点是 API 发出请求后需要一直等待,直到 Scheduler 完成对 Compute 的调度,将结果返回给 API 后 API 才能够继续做后面的工作。 异步调用 API 通过 Messaging 间接调用 Scheduler 就是异步调用。 其特点是 API 发出请求后不需要等待,直接返回,继续做后面的工作。 Scheduler 从 Messaging 接收到请求后执行调度操作,完成后将结果也通过 Messaging 发送给 API。 在 OpenStack 这类分布式系统中,通常采用异步调用的方式,其好处是: 解耦各子服务 子服务不需要知道其他服务在哪里运行,只需要发送消息给 Messaging 就能完成调用。 提高性能 异步调用使得调用者无需等待结果返回。这样可以继续执行更多的工作,提高系统总的吞吐量。 提高伸缩性 子服务可以根据需要进行扩展,启动更多的实例处理更多的请求,在提高可用性的同时也提高了整个系统的伸缩性。而且这种变化不会影响到其他子服务,也就是说变化对别人是透明的。 在后面各章节,我们都能看到 Messaging 的应用。 Database OpenStack 各组件都需要维护自己的状态信息。 比如 Nova 中有虚机的规格、状态,这些信息都是在数据库中维护的。 每个 OpenStack 组件在 MySQL 中有自己的数据库。 小结 Nova 是 OpenStack 中最重要的组件,也是很典型的组件。 Nova 充分体现了 OpenStack 的设计思路。 理解了这种思路,再来学习 OpenStack 的其他组件就能够举一反三,清晰容易很多。 我们在后面 Cinder 和 Neutron 的学习中还会回顾这些设计思路。

资源下载

更多资源
腾讯云软件源

腾讯云软件源

为解决软件依赖安装时官方源访问速度慢的问题,腾讯云为一些软件搭建了缓存服务。您可以通过使用腾讯云软件源站来提升依赖包的安装速度。为了方便用户自由搭建服务架构,目前腾讯云软件源站支持公网访问和内网访问。

Nacos

Nacos

Nacos /nɑ:kəʊs/ 是 Dynamic Naming and Configuration Service 的首字母简称,一个易于构建 AI Agent 应用的动态服务发现、配置管理和AI智能体管理平台。Nacos 致力于帮助您发现、配置和管理微服务及AI智能体应用。Nacos 提供了一组简单易用的特性集,帮助您快速实现动态服务发现、服务配置、服务元数据、流量管理。Nacos 帮助您更敏捷和容易地构建、交付和管理微服务平台。

Spring

Spring

Spring框架(Spring Framework)是由Rod Johnson于2002年提出的开源Java企业级应用框架,旨在通过使用JavaBean替代传统EJB实现方式降低企业级编程开发的复杂性。该框架基于简单性、可测试性和松耦合性设计理念,提供核心容器、应用上下文、数据访问集成等模块,支持整合Hibernate、Struts等第三方框架,其适用范围不仅限于服务器端开发,绝大多数Java应用均可从中受益。

Sublime Text

Sublime Text

Sublime Text具有漂亮的用户界面和强大的功能,例如代码缩略图,Python的插件,代码段等。还可自定义键绑定,菜单和工具栏。Sublime Text 的主要功能包括:拼写检查,书签,完整的 Python API , Goto 功能,即时项目切换,多选择,多窗口等等。Sublime Text 是一个跨平台的编辑器,同时支持Windows、Linux、Mac OS X等操作系统。

用户登录
用户注册