1 前言

在日常的工作开发中,记录业务操作产生的日志是很普遍的操作。通过它可以看到每条数据产生的变化,也能在出现问题的时候快速找到原因。

对于我自己而言,因为我这里记录的日志需要进行一些逻辑判断,并不是简单的一条条文本数据。所以像我之前的写法是将记录日志的代码和业务代码都写在同一个方法中,这样就显得很乱。我一直都想要改造这块,但是却没有一个很好的思路,网上的一些实现方案也不能达到我的预期,所以就一直这么放下了。后来偶然间看到了美团技术团队的一篇文章《如何优雅地记录操作日志?》(我这里斗胆使用了相同的标题),从中获得了很大的启发,借用了其中的一些思想和套路,写出了自己的实现,在这里与大家分享。

在这里十分感谢这篇文章的作者,同时我这里也只是借用了他的一些实现思路,具体的代码实现和美团的不太一样,美团的这篇文章也并没有给出所有代码的具体实现。与其说照着别人的思路往下写,还不如自己重头开发。因为是别人的思路,所以很有可能出现写到一半写不下去的情况。自己进行开发,思路都是自己的,对于二次开发来说也更加得心应手。

相比于美团的版本,我这个版本在功能上做了一些阉割和调整,现在的实现已经能满足公司业务的需要,等后期有时间的话再进行补充和完善。

还有一点需要说明的是:我这里记录的日志是偏向于业务侧的日志,而不是功能性的日志。相比于功能性日志,业务日志因其记录内容的灵活多变,往往更难封装。

完整的代码已放到GitHub:https://github.com/ACoolMonkey/MyLogRecord


2 功能

2.1 快速使用

import com.hys.mylogrecord.customfunction.MyLogRecordSnapshotFunction;
import com.hys.mylogrecord.log.OperationLogTypeEnum;
import com.hys.mylogrecord.util.LogRecordContext;import java.lang.annotation.*;/*** 日志记录** @author Robert Hou* @since 2022年04月21日 19:37**/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface MyLogRecord {/*** 日志类型*/OperationLogTypeEnum type();/*** 关联主键id*/@SpelDynamicTemplate@CustomFunctionString relationId() default "";/*** 操作人id*/@SpelDynamicTemplate@CustomFunctionString operatorId() default "";/*** 操作说明*/@SpelDynamicTemplate@CustomFunctionString description() default "";/*** 保存快照,返回值会被存进缓存中。需要实现{@link MyLogRecordSnapshotFunction}接口,通过{@link LogRecordContext#getSnapshotCache()}方法拿到缓存值* 注:本方法会比预处理的自定义函数还要先执行,同时也就意味着在自定义函数预执行阶段即可拿到缓存值*/@SpelDynamicTemplate@CustomSnapshotFunctionString snapshot() default "";
}

以上是日志记录的注解,除了最后的snapshot配置项之外,其他的都是业务侧的参数,很好理解。snapshot配置项放在后面的章节再进行讲解。另外,除了type配置项之外,其他的配置项都不是必填的,以此来简化使用。愿景是希望能做到不侵入一行业务代码,并且使用起来很方便,不需要太多的配置项内容。

启动类上需要加上@EnableMyLogRecord注解:

@SpringBootApplication
@EnableMyLogRecord(scanBasePackages = "com.hys")
public class MylogrecordApplication {public static void main(String[] args) {SpringApplication.run(MylogrecordApplication.class, args);}}

简单的demo如下:

@MyLogRecord(type = OperationLogTypeEnum.INSERT_PRODUCT,relationId = "123",operatorId = "456",description = "添加商品")
public void simpleTest() {log.info("执行业务操作...");
}

运行结果:

2022-04-26 20:11:16.004  INFO 11052 --- [           main] c.hys.mylogrecord.demo.MyLogRecordTest   : 执行业务操作...
2022-04-26 20:11:16.010  INFO 11052 --- [         task-1] c.h.m.p.s.i.DefaultLogRecordServiceImpl  : operationLogDTO:OperationLogDTO(id=null, type=1, relationId=123, operatorId=456, operateTime=Tue Apr 26 20:11:16 CST 2022, description=添加商品)

实际的操作记录是需要入库的,我这里就用打印日志来进行代替。可以看到,日志实体里的属性被成功赋值。

2.2 动态文本解析

上面例子中的注解上的配置项都是静态文本。而在我们实际的开发中,是不可能这么简单的。所以这里就用到了动态文本解析的功能:

@MyLogRecord(type = OperationLogTypeEnum.INSERT_PRODUCT,relationId = "{#spuId}",operatorId = "{#operatorId}",description = "添加商品 {#productContentDTO.content}")
public void dynamicTemplateTest(Long spuId, Long operatorId, ProductContentDTO productContentDTO) {log.info("执行业务操作...");
}

测试类如下:

@Test
void contextLoads() {ProductContentDTO productContentDTO = new ProductContentDTO();productContentDTO.setContent("商品参数");myLogRecordTest.dynamicTemplateTest(123L, 456L, productContentDTO);
}

运行结果:

2022-04-26 20:48:05.286  INFO 12612 --- [           main] c.hys.mylogrecord.demo.MyLogRecordTest   : 执行业务操作...
2022-04-26 20:48:05.306  INFO 12612 --- [           main] c.h.m.p.s.i.DefaultLogRecordServiceImpl  : operationLogDTO:OperationLogDTO(id=null, type=1, relationId=123, operatorId=456, operateTime=Tue Apr 26 20:48:05 CST 2022, description=添加商品 商品参数)

可以看到,日志实体里的属性值都被动态替换掉了。我这里解析参数使用的是SpEL的语法和API,需要说明的一点是:“#”后面跟着的参数需要和业务方法上的参数名一一对应,否则不会进行识别。

2.3 回调自定义函数

如果操作日志上的动态参数很简单的话、通过业务方法上的参数即可完成拼接,那么确实可以使用上面的语法规则来进行快捷操作。但是很多的情况下是需要自己写一些复杂的逻辑来进行拼接的,这个时候就可以使用自定义函数的功能。首先需要创建一个bean,实现MyLogRecordFunction接口,MyLogRecordFunction接口的代码如下:

/*** 日志记录自定义函数* 注:实现类必须定义成Spring Bean的形式** @author Robert Hou* @since 2022年04月23日 11:27**/
public interface MyLogRecordFunction {/*** 是否在目标方法前执行*/default boolean executeBefore() {return false;}/*** 方法名* 注:需要保证全局唯一,建议加上项目名前缀*/String functionName();/*** 自定义函数*/String apply(Object value);
}

executeBefore方法是用来控制自定义函数的执行时机是在业务方法之前还是之后。functionName是用来作为自定义函数的唯一标识,如果名称重复的话会进行覆盖。apply方法即为自定义函数,通过executeBefore方法的返回值来确定回调的时机。

实现类如下所示:

import com.hys.mylogrecord.customfunction.MyLogRecordFunction;
import org.springframework.stereotype.Component;/*** 添加商品操作说明** @author Robert Hou* @since 2022年04月26日 22:49**/
@Component
public class InsertProductDescLogRecordFunction implements MyLogRecordFunction {@Overridepublic String functionName() {return "product_insertProduct_desc";}@Overridepublic String apply(Object value) {String content;if (value instanceof String) {content = (String) value;} else {return null;}return content + "123";}
}

apply方法上的value参数即为在注解上传过来的值,之后会看到。这里演示的效果是对content进行了简单的拼接操作,然后返回。实际上可以进行任何的复杂操作,因为是个bean,所以可以调用其他的接口来实现更复杂的逻辑。需要注意的是:即使apply方法的实现很简单、不需要调用其他接口的话,也仍然需要注册成bean对象。因为我在项目启动的时候只会扫描bean对象,并加载进缓存中,Class对象是不会进行缓存的。

@MyLogRecord(type = OperationLogTypeEnum.INSERT_PRODUCT,relationId = "{#spuId}",operatorId = "{#operatorId}",description = "添加商品 {product_insertProduct_desc{#productContentDTO.content}}")
public void customFunctionTest(Long spuId, Long operatorId, ProductContentDTO productContentDTO) {log.info("执行业务操作...");
}

注解的使用如上所示,可以看到,相比于动态文本解析的版本,只是在description配置项里对{#productContentDTO.content}再进行了一层包装,这里的“product_insertProduct_desc”即为上面InsertProductDescLogRecordFunction自定义函数里的functionName。需要注意的是:自定义函数和动态文本解析一样,需要用大括号包围住才能进行识别。不同的是,自定义函数名前不能有“#”,否则就会识别成动态文本解析了。自定义函数的functionName只能输入数字、大小写字母、下划线和“$”,在项目启动的时候会进行校验。

下面来看下执行结果:

2022-04-26 23:26:30.073  INFO 36996 --- [           main] c.hys.mylogrecord.demo.MyLogRecordTest   : 执行业务操作...
2022-04-26 23:26:30.154  INFO 36996 --- [           main] c.h.m.p.s.i.DefaultLogRecordServiceImpl  : operationLogDTO:OperationLogDTO(id=null, type=1, relationId=123, operatorId=456, operateTime=Tue Apr 26 23:26:30 CST 2022, description=添加商品 商品参数123)

可以看到“商品参数”后面是拼接了“123”的。下面来看另一个例子,这个例子可以更好地说明自定义函数存在的意义。现在我要修改商品,记录的日志格式为“修改前:xxx,修改后:xxx”,那么对于这个例子,我需要写两个自定义函数,一个是业务方法执行前,一个是业务方法执行后:

import com.hys.mylogrecord.customfunction.MyLogRecordFunction;
import org.springframework.stereotype.Component;/*** 修改商品操作说明(目标方法前执行)** @author Robert Hou* @since 2022年04月27日 00:07**/
@Component
public class UpdateProductDescExecuteBeforeLogRecordFunction implements MyLogRecordFunction {@Overridepublic boolean executeBefore() {return true;}@Overridepublic String functionName() {return "product_updateProduct_desc_executeBefore";}@Overridepublic String apply(Object value) {return "业务方法执行前的" + value;}
}
import com.hys.mylogrecord.customfunction.MyLogRecordFunction;
import org.springframework.stereotype.Component;/*** 修改商品操作说明(目标方法后执行)** @author Robert Hou* @since 2022年04月26日 23:49**/
@Component
public class UpdateProductDescExecuteAfterLogRecordFunction implements MyLogRecordFunction {@Overridepublic String functionName() {return "product_updateProduct_desc_executeAfter";}@Overridepublic String apply(Object value) {return "业务方法执行后的" + value;}
}

可以看到,UpdateProductDescExecuteBeforeLogRecordFunction相比于UpdateProductDescExecuteAfterLogRecordFunction多覆写了executeBefore方法,使其返回true,代表业务方法执行前进行回调。

具体的使用如下:

@MyLogRecord(type = OperationLogTypeEnum.UPDATE_PRODUCT,relationId = "{#spuId}",operatorId = "{#operatorId}",description = "修改商品 修改前:“{product_updateProduct_desc_executeBefore{#productContentDTO.content}}”,修改后:“{product_updateProduct_desc_executeAfter{#productContentDTO.content}}”")
public void anotherCustomFunctionTest(Long spuId, Long operatorId, ProductContentDTO productContentDTO) {log.info("执行业务操作...");
}

运行结果:

2022-04-27 00:21:52.884  INFO 1248 --- [           main] c.hys.mylogrecord.demo.MyLogRecordTest   : 执行业务操作...
2022-04-27 00:21:52.884  INFO 1248 --- [           main] c.h.m.p.s.i.DefaultLogRecordServiceImpl  : operationLogDTO:OperationLogDTO(id=null, type=2, relationId=123, operatorId=456, operateTime=Wed Apr 27 00:21:52 CST 2022, description=修改商品 修改前:“业务方法执行前的商品参数”,修改后:“业务方法执行后的商品参数”)

2.4 全局缓存

在对公司项目代码进行切换成日志注解方式的过程中,我发现了有一种情况是不能满足的,这也就触发了我实现全局缓存的功能。在一些场景中,我并不是要记录“修改前:xxx,修改后:xxx”格式的日志,而是要具体记录到底是哪个字段发生了修改,哪个字段删除了,类似这样格式的日志。这个需求如果是通过之前的自定义函数和动态文本解析是实现不了的,但是实现不了的原因不光是因为目前的自定义函数不支持多个参数传入,更重要的原因是因为这里需要对多个实体的执行时机进行区分,第一个实体是需要在业务方法执行前获取,而第二个实体是需要在业务方法执行后获取。最后拿到这两个实体,再进行比对。

借用开头引用的美团文章里的一句话,遇到这种情况下你可以去和产品经理PK,但是大多数情况下你是不可能成功说服的,因为这个需求确实很合理。即使能说服,对于一个有着高要求的程序员来说也是不能妥协的,必须要想办法兼容。

这里的解决办法有很多种,比如说可以对自定义函数进行多参数传入的改造,同时动态文本解析需要和自定义函数一样,支持执行时机的区分。但是这种方案对于我来说实现的代价太大了,对于使用者来说也更加繁琐。我最后的实现方案是增加全局缓存的功能,也就是上面MyLogRecord注解里的snapshot配置项的作用。snapshot里面也可以配置自定义函数和动态文本解析(如果是静态文本的话,不会起任何作用),但是这个方法返回的结果并不会用于日志展示,而是会被缓存进ThreadLocal中,在当前方法的整个调用期间有效。同时snapshot配置项的返回值会比预处理的自定义函数还要先执行,这也就意味着在自定义函数预执行阶段即可拿到缓存值。

跟自定义函数类似,实现全局缓存也需要实现一个接口:MyLogRecordSnapshotFunction:

import com.hys.mylogrecord.util.LogRecordContext;/*** 日志记录保存快照* 注:实现类必须定义成Spring Bean的形式** @author Robert Hou* @since 2022年04月25日 17:45**/
public interface MyLogRecordSnapshotFunction {/*** 方法名* 注:需要保证全局唯一,建议加上项目名前缀*/String functionName();/*** 自定义函数* 注:本方法的返回值会被存进缓存中,通过{@link LogRecordContext#getSnapshotCache()}方法拿到缓存值*/Object snapshotApply(Object value);
}

可以看到,这个接口和之前的MyLogRecordFunction接口是很类似的,只不过是没有executeBefore方法而已。这是因为MyLogRecordSnapshotFunction接口的执行时机固定是在最前面。同时还有一个细节不同就是:MyLogRecordSnapshotFunction的snapshotApply方法因为是用来做缓存的,任何类型的数据都可以进行缓存,所以返回值是Object类型;而MyLogRecordFunction的apply方法的返回值是用来做日志展示用的,所以写死为String类型。

实现类如下所示:

import com.hys.mylogrecord.customfunction.MyLogRecordSnapshotFunction;
import com.hys.mylogrecord.demo.dto.ProductContentDTO;
import org.springframework.stereotype.Component;/*** 修改商品保存快照** @author Robert Hou* @since 2022年04月27日 01:38**/
@Component
public class UpdateProductLogRecordSnapshotFunction implements MyLogRecordSnapshotFunction {@Overridepublic String functionName() {return "product_updateProduct_snapshot";}@Overridepublic Object snapshotApply(Object value) {Long spuId;if (value instanceof Long) {spuId = (Long) value;} else {return null;}ProductContentDTO productContentDTO = new ProductContentDTO();productContentDTO.setContent("执行业务方法前的实体");return productContentDTO;}
}

可以看到这里是将一个ProductContentDTO实体放进了缓存中,下面来看下description配置项配置的自定义函数:

import com.hys.mylogrecord.customfunction.MyLogRecordFunction;
import com.hys.mylogrecord.demo.dto.ProductContentDTO;
import com.hys.mylogrecord.util.LogRecordContext;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;import java.util.List;
import java.util.Objects;/*** 修改商品操作说明** @author Robert Hou* @since 2022年04月27日 01:44**/
@Component
public class UpdateProductDescLogRecordSnapshotFunction implements MyLogRecordFunction {@Overridepublic String functionName() {return "product_updateProduct_desc_snapshot";}@Overridepublic String apply(Object value) {List<Object> snapshots = LogRecordContext.getSnapshotCache();if (CollectionUtils.isEmpty(snapshots)) {return null;}Object snapshot = snapshots.get(0);ProductContentDTO oldProductContent;if (snapshot instanceof ProductContentDTO) {oldProductContent = (ProductContentDTO) snapshot;} else {return null;}ProductContentDTO productContent;if (value instanceof ProductContentDTO) {productContent = (ProductContentDTO) value;} else {return null;}return getDiff(productContent, oldProductContent);}private String getDiff(ProductContentDTO productContent, ProductContentDTO oldProductContent) {if (Objects.equals(productContent, oldProductContent)) {return "无变化";}String content = null;if (productContent != null) {content = productContent.getContent();}String oldContent = null;if (oldProductContent != null) {oldContent = oldProductContent.getContent();}if (StringUtils.isNotBlank(oldContent) && StringUtils.isBlank(content)) {return "\"" + oldContent + "\"删除了";} else if (StringUtils.isNotBlank(oldContent) && StringUtils.isNotBlank(content)) {return "\"" + oldContent + "\"修改为\"" + content + "\"";}return "";}
}

可以看到,在apply方法中是通过LogRecordContext.getSnapshotCache()的方式来拿到之前缓存进去的ProductContentDTO对象,然后通过调用getDiff方法来进行比对并返回。

LogRecordContext.getSnapshotCache()方法的返回值是个List集合,这也就意味着可以存进多个缓存对象,只需要在snapshot配置项中配置多个自定义函数或动态文本解析即可。

下面来看下注解的使用:

@MyLogRecord(type = OperationLogTypeEnum.UPDATE_PRODUCT,relationId = "{#spuId}",operatorId = "{#operatorId}",description = "{product_updateProduct_desc_snapshot{#productContentDTO}}",snapshot = "{product_updateProduct_snapshot{#spuId}}")
public void snapshotTest(Long spuId, Long operatorId, ProductContentDTO productContentDTO) {log.info("执行业务操作...");
}

snapshot配置项配置的是上面的UpdateProductLogRecordSnapshotFunction类中的内容,而description配置项配置的是UpdateProductDescLogRecordSnapshotFunction类中的内容。下面来看下执行结果:

2022-04-27 02:16:50.944  INFO 33680 --- [           main] c.hys.mylogrecord.demo.MyLogRecordTest   : 执行业务操作...
2022-04-27 02:16:50.944  INFO 33680 --- [           main] c.h.m.p.s.i.DefaultLogRecordServiceImpl  : operationLogDTO:OperationLogDTO(id=null, type=2, relationId=123, operatorId=456, operateTime=Wed Apr 27 02:16:50 CST 2022, description="执行业务方法前的实体"修改为"商品参数")

可以看到,成功打印出了“"执行业务方法前的实体"修改为"商品参数"”的内容。

因为我这里实现的自定义函数是接口形式,而不是抽象类,所以可以创建一个实现类,同时实现MyLogRecordSnapshotFunction接口和MyLogRecordFunction接口,这样是不冲突的。如果同时将executeBefore方法的返回值置为true的话,那么这可以用来作为预处理的自定义函数的多参数入参的替代实现。这里需要说明的一点是:自定义函数和全局缓存用的是两个HashMap进行缓存,所以即便共用同一个functionName的话,也不会有问题。


3 待优化点

  1. 暂不支持注解的嵌套使用;
  2. 暂不支持批量记录日志的功能,目前只能通过批量调用含有日志注解的方法来间接实现;
  3. 日志持久化方法可以提供覆写的逻辑,就跟自定义函数一样,供使用者自己来实现。这样可以有更多的玩法(使用者可以对日志记录的功能自己做增强;或者说不入数据库,插入到ES或MQ中;甚至说可以不局限在插入日志的功能。比方说可以作为canal的很好的补充。canal的局限性就在于它只能监听数据库数据的变化,如果是RPC调用的话,则可以用这种方式来进行补充完善);
  4. 自定义函数目前只支持一个参数,考虑多个参数的实现逻辑。

原创不易,未得准许,请勿转载,翻版必究

如何优雅地记录操作日志?相关推荐

  1. 【实践】万字干货:如何优雅地记录操作日志?(附代码)

    猜你喜欢 1.如何搭建一套个性化推荐系统? 2.从零开始搭建创业公司后台技术栈 3.某视频APP推荐详解(万字长文) 4.微博推荐算法实践与机器学习平台演进 5.腾讯PCG推荐系统应用实践 6.强化学 ...

  2. 美团的系统是如何记录操作日志?

    来源:美团技术团队 操作日志几乎存在于每个系统中,而这些系统都有记录操作日志的一套 API.操作日志和系统日志不一样,操作日志必须要做到简单易懂.所以如何让操作日志不跟业务逻辑耦合,如何让操作日志的内 ...

  3. 记录操作日志(JAVA版某大厂基础实践)

    1. 操作日志的使用场景 2. 实现方式 2.1 使用 Canal 监听数据库记录操作日志 2.2 通过日志文件的方式记录 2.3 通过 LogUtil 的方式记录日志 2.4 方法注解实现操作日志 ...

  4. 如何使用SpringBoot AOP 记录操作日志、异常日志?

    点击上方蓝色"方志朋",选择"设为星标" 回复"666"获取独家整理的学习资料! 作者:咫尺的梦想_w cnblogs.com/wm-dv/ ...

  5. Appfuse:记录操作日志

    appfuse的数据维护操作都发生在***form页面,与之对应的是***FormController,在Controller中处理数据的操作是onSubmit方法,既然所有的操作都通过onSubmi ...

  6. 用aspect在springboot中记录操作日志至数据库的详细过程

    代码来自若依管理系统的后台,我截取的其中用于记录操作日志的部分 1.切面 2.操作日志表 3.spring工具类 4.客户端工具类 异步工厂(产生任务用) 异步任务管理器 5.服务层 6.控制层 1. ...

  7. slf4j注解log报错_SpringBoot自定义日志注解,用于数据库记录操作日志,你用过吗?...

    大家好,我是程序员7歌! 今天我将为大家讲解如何通过自定义注解记录接口访问日志.一般的开发中,有两种方式可以记录日志信息,第一种:把接口日志信息保存到日志文件中,第二种:把接口操作日志保存到数据库中, ...

  8. JAVA记录操作日志步骤

    项目地址:https://gitee.com/Selegant/logs-demo.git 说明 系统日志不论是在日常的管理还是维护中都会起到很大的作用,但是在日志的记录中通常会存在很多的问题 日志记 ...

  9. SpringBoot AOP 记录操作日志、异常日志

    使用SpringBoot AOP 记录操作日志.异常日志 我们在做项目时经常需要对一些重要功能操作记录日志,方便以后跟踪是谁在操作此功能.在操作某些功能时也有可能会发生异常,但是每次发生异常要定位原因 ...

最新文章

  1. C#用了多线程界面还是卡死
  2. ERP中的合并会计报表
  3. Linux线程——线程同步
  4. 【转】Linux下软件安装的几种方式
  5. 1.详细说明微型计算机的组成,第1章微型计算机系统导论.ppt
  6. 读取MySQL二进制文件_MYSQL: mysqlbinlog读取二进制文件报错read_log_event()
  7. php和python-PHP与Python语言有哪些区别之处?选择哪一个好?
  8. HTML5 Canvas雨滴下落动画 超逼真
  9. linux 安装talib
  10. Android Studio 安装TinyPng插件
  11. excel linux时间戳转换成日期,Excel将Unix时间戳转换为日期
  12. 深圳计算机学校排名2015年,2015年深圳各区小学排名汇总
  13. 由中序和后序(前序)序列求前序(后序)序列
  14. 太酷了,手把手教你用 Python 绘制桑基图
  15. python解压zip_用Python处理ZIP压缩包
  16. linux deploy目录形式,安装Linux Deploy
  17. Neo4j 查询语法入门
  18. vs 无法启动程序c语言,vs2013运行c语言出现:无法查找或打开 PDB 文件。
  19. sqlldr的学习与总结
  20. win10的wsapp把电脑卡死

热门文章

  1. 合肥学院历届计算机对口招生真题,合肥学院2020年对口招生考试技能测试考试纲要...
  2. 打印机连接电脑正常但是文件被挂起是怎么回事?
  3. Linux将两个文件合并
  4. linux ssh公钥文件,linux配置ssh公钥认证
  5. 跨部门沟通难题--高手项目经理和PMO如何解决?【可乐洞察】
  6. 7年老Android一次操蛋的面试经历,附小技巧
  7. Apache Dubbo的爱奇艺之旅
  8. 宝宝为什么会出现乳糖不耐受?
  9. 引领语言智能革命的巨型语言模型 ChatGPT PK Google Bard , Anthropic
  10. 弘辽科技告诉你2020新手如何开网店?这些技巧一定要掌握!