本文最后更新于:2023年4月24日 上午
move on 0x01 多线程 在公司进行 BUG 处理的时候遇到一个很诡异的问题,流程是这样的:
前端根据提供搜索参数,后台根据搜索参数在数据库进行过滤查询
公司使用自己封装的 ORM 框架对数据库进行条件查询
返回查询出的结果
问题就出在查询的逻辑中,下面是出现的几个问题。
切换页码,首次在页面上显示的总条数不一样,从第二页开始固定在一个值。(核心问题)
时不时回出现 No Column id found
Jpa 出现查询事务报错
1. 并行流与线程安全 先说说 No Column id found
的问题,如下是发生问题的地方,table(TableSchema)
是模型映射到数据库的一个映射类,主要作用于实体类上,FieldSchema
则是属于 table
的映射字段,这里的作用就是取出映射的数据库字段。
乍一看好像没什么问题,使用内部迭代的方式,写法很简洁,可读性很好,但是这里使用的是 parallelStream
而不是 stream
,问题就出在这里,最终取出来的数据库映射字段不是完整的,导致每次进行映射的时候就会出现 No Column id
的问题。
1 2 3 Map<String, FieldSchema> fields = new HashMap <>(table.getKeys().size()); table.getKeys().parallelStream().forEach(key -> fields.put(key.getProperty_id(), key));
这里使用一个例子来展示线程安全的问题:一个很简单的例子,计算数组每个元素的倍数,在多次测试的情况下,得出的总数和原来数组的总数不一致。
1 2 3 4 5 6 7 8 9 10 @Test public void testParallelTrap () { List<Integer> data = new ArrayList <>(10000 ); for (int i = 1 ; i <= 10000 ; i++) { data.add(i); } List<Integer> result = new ArrayList <>(); data.parallelStream().forEach(key -> result.add(key * 2 )); System.out.println("the result size is: " + result.size()); }
正确的方式是,在 parallelStream
中使用线程安全的容器,或者使用 stream
中的结束方法 collect()
配合 Collectors
的toList(), toMap(),toSet()
方法。
1 2 3 4 5 6 7 8 9 10 Map<String, FieldSchema> tables = new Hashtable (); Map<String, FieldSchema> conTables = new ConcurrentHashMap (); Map<String, FieldSchema> syncTables = Collections.synchronizedMap(new HashMap <>()); table.getKeys().parallelStream().forEach(key -> tables.put(key.getProperty_id(), key)); Map<String, FieldSchema> fields = table.parallelStream().collect( Collectors.toMap(FieldSchema::getProperty_id, key -> key));
2. static 与线程安全 找了很久,终于发现出现问题的地方了,由于从数据库获取数据到进行数据转换是多线程方式,目的是为了提高搜索效率;由于 findByIds
以及外部是多线程操作,同时 TableSchema
被定义为静态变量,导致多线程获取的表名会出现重复的情况,所以每次从数据库查询获取的总数不一样。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public class Address { private static TableSchema schema; public static TableSchema geSchema (String deviceId) { if (deviceId.length() < LENGTH) { throw new IndexOutOfBoundsException ("device id length must greater then 5" ); } schema.setName(getTableName(deviceId)); return schema; } }public List<Policy> findByIds (final String deviceId, final List<UUID> ids) throws Exception { final TableSchema schema = Policy.getPolicySchema(deviceId); Query query = new Query (); query.setSchema(schema); }
多线程中的静态方法:多个线程并发的调用某个类的静态方法,如果静态方法内部没有操作静态成员,那么就不会出现线程安全问题。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @Test public void testStaticMemberTrap () { for (int i = 1 ; i <= 5 ; i++) { int finalI = i; Thread thread = new Thread (() -> { Runner.print(finalI); }); thread.start(); } }static class Runner { public static void print (int number) { int count = 10 ; count = count * number; System.out.println("result: " + count); } }
但是如果多个线程调用静态方法时,静态方法内部操作了本类的静态成员变量,那么就会出现线程安全问题:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @Test public void testStaticMemberTrap () { for (int i = 1 ; i <= 5 ; i++) { int finalI = i; Thread thread = new Thread (() -> { Runner.print(finalI); }); thread.start(); } }static class Runner { private static int count = 10 ; public static void print (int number) { count = count * number; System.out.println("result: " + count); } }
对于静态成员变量来说,属于类的成员变量,所有类的对象同时共享,值的修改对于其他对象均可见,不恰当的使用则会造成线程安全问题。解决的方法是,不要在静态方法里面使用类的静态变量,应该在方法内部使用局部变量,才不会造成线程安全的问题。
对于项目里的代码来说,需要改成如下方式:由于 TableSchema
的数据在静态代码块就已经填充完毕,故使用克隆的形式重新创建一份新的 TableSchema
。
1 2 3 4 5 6 7 8 9 public static TableSchema getSchema (String deviceId) { if (deviceId.length() < LENGTH) { throw new IndexOutOfBoundsException ("设备id必须大于5才合法" ); } TableSchema cloneAddressSchema = new TableSchema (); BeanUtils.copyProperties(schema, cloneAddressSchema); cloneAddressSchema.setName(getTableName(deviceId)); return cloneAddressSchema; }
0x02 Spring JPA 1. 常用注解 @DynamicInsert
:作用于使用 @Entity
标注的实体类上,目的是为了插入语句的时候动态插入,当成员变量为空的时候不会参与到插入语句中。
@DynamicUpdate
:作用于使用 @Entity
标注的实体类上,目的是为了更新语句的时候动态更新,当成员变量为空的时候不会参与到更新语句中。
@OrderBy
:作用于字段,生成 SQL
语句的时候指定特定的字段进行排序。
@GeneratedValue
:作用于字段,指定当前实体类主键的生成策略。
@Enumerated
:作用于枚举字段,把枚举字段的值映射到数据库的字符串字段。
@Transient
:作用于字段,指定字段不会被持久化到数据库。
@TypeDef
:作用于类上或 package-info.java
上,搭配 @Type
注解使用。
@TypeDefs
:作用于类上或 package-info.java
上,当一个类的字段包含多个不同的自定义映射类型时,可以使用。
@Type
:作用于字段上或方法上,定义某字段类型为自定义映射类型,搭配 @TypeDef
注解使用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 @Entity @Table @TypeDefs(value = { @TypeDef(name = "jsonb", typeClass = JsonBinaryType.class) }) @Data @DynamicInsert @DynamicUpdate public class Address { @Id private Long id; @Column private String city; @Column private String area; @Column private String street; @Type(type = "jsonb") @Column(columnDefinition = "jsonb") private String detail; @Column private Date createdTime; @Column private Date updatedTime; }
2. Entity 的生命周期
每个实体在 persistence context
中都存在四种状态:
New: 新建对象,没有和 persistence context
建立关系,修改实体类数据不会触发更新。
Transient & Managed: 持久化和被托管状态的对象,通过 EntityManager#persist
,JPQL
,等查询方式和 persistence context
建立联系,任何实体类的改动都会触发数据库的更新。
Detached: 游离状态,从 Transient & Managed
状态转变而来,使用 EntityManager#detach
,clear
,close
,evict
等方法均会使实体里从持久化状态转变到游离状态,脱离和 persistence context
的联系,想要重新和 persistence context
建立联系需要使用 EntityManager#merge
方法。
Removed: 移除状态,使用 EntityManager#remove
可以转换为移除状态,但是数据库记录不会里面删除;在事务提交或 EntityManager#flush
之前 persistence context
会生成删除语句等待删除记录。
Managed
状态下的数据保存,更新以及删除数据下的 Removed
状态,数据都不会立即更新到数据库,只有当你事务提交 (@Transactional
) 或者 em.flush()
,才会立即更新到数据库。
3. Repository 自定义方法与 @Transactional/@Modifying
在 JPA Repository
中,可以通过继承 JpaRepository
接口的形式得到现成的 CRUD 方法,但是有的时候 JpaRepository
中的方法不能满足需求,就需要自定义接口方法。
需要注意的是,如果自定义 (增删改) 方法是使用 @Query
的方式,那么需要搭配 @Modifying
注解用于自定义接口方法,告诉 JPA 这是一个增删改语句。同时,不管是使用 @Query
的自定义接口方法或其他自定义接口方法,都应该在业务层的方法中使用 @Transactional
注解修饰。
0x03 postgresql 1. 安装 使用 docker 方式快速安装:
1 docker run -d --name postgres -p 5432:5432 -e POSTGRES_PASSWORD=123 -v postgres:/var/lib/postgresql/data postgres:latest
更好的 cli 客户端:pgcli
,安装方式如下。
使用方式:
1 pgcli -h host -u username -d schema
创建用户:
1 CREATE USER username WITH PASSWORD '*****' ;
授权数据给刚刚创建的用户:
1 GRANT ALL PRIVILEGES ON DATABASE database TO username;
授权数据库里的表:
1 GRANT ALL PRIVILEGES ON all tables in schema public TO username;
2. 常用函数 由于公司项目中的一些数据都存在 postgresql
的 jsonb
字段中,所以有的时候写 sql
查询特别麻烦,记一下常用的 sql
函数。
jsonb_extract_path/jsonb_extract_path_text
:从 jsonb
字段中提取某个键的值数据,text 结尾标识转换为 text
类型,第一个参数为要提取的 jsonb
数据,第二个字段为提取路径,可选还有第三等等字段 (取决于值的深度),为提取路径的路径。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 select jsonb_extract_path_text('{ "id": 1, "area": { "name": "天河区", "code": { "type": "String", "code": 1111 } }, "city": "广州市", "detail": null, "street": "", "createdTime": null, "updatedTime": null }' ::jsonb, 'area' , 'code' , 'type' );SQL
jsonb_array_elements/jsonb_array_elements_text
:从 jsonb
数组中遍历并提取每个数组元素。
1 select fruits from jsonb_array_elements_text('["Apple", "Banana", "Orange", "Grape"]' ) as fruits;
jsonb_each/jsonb_each_text
:从 jsonb
数据中提取键和值数据,最终结果形成两列。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 select key, value from jsonb_each_text('{ "id": 1, "area": { "value": { "name": "天河区", "type": "String", "code": 10001 } }, "city": "广州市", "detail": null, "street": "", "createdTime": null, "updatedTime": null }' ::jsonb)
3. 判断 IP
地址是否包含 在公司里写代码的时候,有判断 IP 地址是否包含的需求,这个时候刚好发现 postgresql
提供此功能,现在记录一下:
使用 inet
函数可以进行如下操作:inet
不支持 192.168.30.51-12
这种格式,当然这种格式本身就有问题,如果是 192.168.30.1-192.168.30.100
,需要使用 split_part()
进行分割。
Operator
Description
Example
<
is less than
inet '192.168.1.5' < inet '192.168.1.6'
<=
is less than or equal
inet '192.168.1.5' <= inet '192.168.1.5'
=
equals
inet '192.168.1.5' = inet '192.168.1.5'
>=
is greater or equal
inet '192.168.1.5' >= inet '192.168.1.5'
>
is greater than
inet '192.168.1.5' > inet '192.168.1.4'
<>
is not equal
inet '192.168.1.5' <> inet '192.168.1.4'
<<
is contained within
inet '192.168.1.5' << inet '192.168.1/24'
<<=
is contained within or equals
inet '192.168.1/24' <<= inet '192.168.1/24'
>>
contains
inet '192.168.1/24' >> inet '192.168.1.5'
>>=
contains or equals
inet '192.168.1/24' >>= inet '192.168.1/24'
~
bitwise NOT
~ inet '192.168.1.6'
&
bitwise AND
inet '192.168.1.6' & inet '0.0.0.255'
`
`
bitwise OR
+
addition
inet '192.168.1.6' + 25
-
subtraction
inet '192.168.1.43' - 36
-
subtraction
inet '192.168.1.43' - inet '192.168.1.19'
0x04 序列化 1. 对象之间互相引用导致序列化栈溢出 在使用 JPA
进行一对多处理的时候,进行了如下的模型定义:一个作者可以有多本图书,多个图书对应一本读书,两个实体类之间存在互相引用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 @Entity @Table @Data public class Author implements Serializable { @Id @GeneratedValue(strategy = GenerationType.AUTO) private Integer id; @Column private String name; @OneToMany(mappedBy = "author", fetch = FetchType.EAGER) private List<Book> bookList; }@Entity @Table @Data public class Book implements Serializable { @Id @GeneratedValue(strategy = GenerationType.AUTO) private Integer id; @Column private String title; @ManyToOne(fetch = FetchType.EAGER) private Author author; }
测试代码:无论是序列化 Book 还是 Author,都会触发 com.fasterxml.jackson.databind.JsonMappingException: Infinite recursion (StackOverflowError)
的报错,因为 Author
实体类的 toString
包含了 Book
实体类,而 Book
实体类中的 toString
又包含了 Author
,这就造成了无限循环引用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @Test public void testReferenceError () throws JsonProcessingException { Author author = new Author (); author.setId(1 ); author.setName("东野圭吾" ); Book book1 = new Book (); book1.setId(1 ); book1.setAuthor(author); book1.setTitle("虚无的十字架" ); Book book2 = new Book (); book2.setId(2 ); book2.setAuthor(author); book2.setTitle("湖畔" ); author.setBooks(Arrays.asList(book1, book2)); ObjectMapper mapper = new ObjectMapper (); String asString = mapper.writeValueAsString(author); System.out.println(asString); }
解决的方法有很多:
重写 toString
方法,在某一方的 toString
方法中忽略另一方在本类中的值。
在需要忽略的类的字段上添加 @JsonIgnore
注解。
在多的一方字段使用 @JsonBackReference
,在一的地方使用 @JsonManagedReference
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 @Entity @Table @Data public class Author implements Serializable { @Id @GeneratedValue(strategy = GenerationType.AUTO) private Integer id; @Column private String name; @OneToMany(mappedBy = "author", fetch = FetchType.EAGER) @JsonManagedReference private List<Book> books; }@Entity @Table @Data public class Book implements Serializable { @Id @GeneratedValue(strategy = GenerationType.AUTO) private Integer id; @Column private String title; @ManyToOne(fetch = FetchType.EAGER) @JsonBackReference private Author author; }
前面几种方式达到的目的都是使得包含循环引用的字段在 Json
字段中消失,下面这些方式则保留了循环引用的字段,只不过更改了 Json
的结构类型。
使用 @JsonIdentityInfo
用以实体类,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 @Entity @Table @Data @JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id") public class Book implements Serializable { @Id @GeneratedValue(strategy = GenerationType.AUTO) private Integer id; @Column private String title; @ManyToOne(fetch = FetchType.EAGER) private Author author; }@Entity @Table @Data @JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id") public class Author implements Serializable { @Id @GeneratedValue(strategy = GenerationType.AUTO) private Integer id; @Column private String name; @OneToMany(mappedBy = "author", fetch = FetchType.EAGER) private List<Book> books; }
写个测试类测试下序列化,正常序列化,只不过是把循环引用的对象存储成另一种格式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @Test public void testReferenceError () throws JsonProcessingException { Author author = new Author (); author.setId(1 ); author.setName("东野圭吾" ); Book book1 = new Book (); book1.setId(1 ); book1.setAuthor(author); book1.setTitle("虚无的十字架" ); Book book2 = new Book (); book2.setId(2 ); book2.setAuthor(author); book2.setTitle("湖畔" ); author.setBooks(Arrays.asList(book1, book2)); ObjectMapper mapper = new ObjectMapper (); String authorString = mapper.writeValueAsString(author); String book1String = mapper.writeValueAsString(book1); System.out.println(authorString); System.out.println(book1String); }
2. 前后端 Long 的处理 在公司开发的时候,在与前后端联调的时候碰到一个数据类型序列化的问题,Java 传递一个 Long 类型的数据时,对于前端而言,Javascript
无法处理 Long 类型的数据,会导致精度丢失,下面是几种解决方法:
使用 jackson
的 @JsonSerialize
用于实体类的 Long 字段上。
1 2 @JsonSerialize(using = ToStringSerializer.class) private Long id;
使用 jackson
的 @JsonFormat
用于实体类的 Long 字段上。
1 2 @JsonFormat(shape = JsonFormat.Shape.STRING) private Long id;
前面两种方式都是作用于实体类的字段,每个实体类都要设置一遍,特别麻烦,下面是针对整个项目的全局配置:通过实现WebMvcConfigurer#configureMessageConverters
接口方法,配置整个项目全局的 Long 类型转换。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @EnableWebMvc @Configuration public class WebConfig implements WebMvcConfigurer { @Override public void configureMessageConverters (List<HttpMessageConverter<?>> converters) { MappingJackson2HttpMessageConverter jackson2HttpMessageConverter = new MappingJackson2HttpMessageConverter (); ObjectMapper objectMapper = new ObjectMapper (); SimpleModule simpleModule = new SimpleModule (); simpleModule.addSerializer(Long.class, ToStringSerializer.instance); simpleModule.addSerializer(Long.TYPE, ToStringSerializer.instance); objectMapper.registerModule(simpleModule); jackson2HttpMessageConverter.setObjectMapper(objectMapper); converters.add(jackson2HttpMessageConverter); } }
3. 反序列化无视未知字段 公司主要使用 jackson
进行序列化和反序列化处理,主要原因是 SpringBoot
自带,以及 jackson
支持 Java 接口类型 json
动态序列化和反序列化
在一次对数据库中的 json
字段数据进行反序列化的过程中,发现报了一个错:
1 2 com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException: Unrecognized field "age" (class com .root2z.practice.model.Person), not marked as ignorable (2 known properties: "id" , "name" ]) at [Source: (String)"{" id":1," name":" mike"," age":12}" ; line: 1 , column: 31 ] (through reference chain: com.root2z.practice.model.Person["age" ])
这里的意思是说在反序列化 json
的时间检测到了为识别的 json
字段映射到 Person
类上,这里 json
中的 age
字段在 Person
类中是没有定义的,序列化就会报错。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Entity @Table @Data public class Person { @Id private Integer id; @Column private String name; }@Test public void deserializeUnknownField () throws JsonProcessingException { String json = "{\"id\":1,\"name\":\"mike\",\"age\":12}" ; ObjectMapper objectMapper = new ObjectMapper (); Person person = objectMapper.readValue(json, Person.class); System.out.println(person); }
解决的方法:
在类上使用 @JsonIgnoreProperties(ignoreUnknown = true)
注解告诉 jackson
无视 json
中未知字段
使用 ObjectMapper
配置 DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES
属性。
方法 1 的标记在需要反序列化的类上,主要每个需要反序列化的类都需要设置一遍,通常是使用第二种方式,设置全局的 ObjectMapper
属性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @Entity @Table @Data @JsonIgnoreProperties(ignoreUnknown = true) public class Person { @Id private Integer id; @Column private String name; }@Test public void deserializeUnknownField () throws JsonProcessingException { String json = "{\"id\":1,\"name\":\"mike\",\"age\":12}" ; ObjectMapper objectMapper = new ObjectMapper (); objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false ); Person person = objectMapper.readValue(json, Person.class); System.out.println(person); }
0x05 其他 1. 代码规范 (1). 集合判空 不要使用集合的 isEmpty()
方法来判空:
1 2 3 4 5 6 7 public void testFindAllAddresses () { List<Address> addresses = addressService.findAll(); if (addresses.isEmpty()) { return ; } }
正确的方式:应该同时判断 null
和 isEmpty()
,或者使用第三方的 CollectionUtils.isEmpty
来判空。
1 2 3 4 5 6 7 8 public void testFindAllAddresses () { List<Address> addresses = addressService.findAll(); if (CollectionUtils.isEmpty(addresses)) { return ; } }
(2). 异常捕捉 不应该把异常捕获不抛出:捕获了异常,不进行处理,也不抛出,在错误定位时也无法定位,用户也无法感知到错误,这是不正确的。
1 2 3 4 5 6 7 public void testGetAddressById (id) { try { Address address = addressService.getAddress(1L ); } catch (NoSuchMethodException e) { } }
正确的方式,转换成系统自定义的异常,同时携带上下文信息:
tips:在 catch
里抛出了异常就不需要打印异常日志了。
1 2 3 4 5 6 7 8 public void testGetAddressById (Long id) throws NotFoundAddressException{ try { Address address = addressService.getAddress(id); } catch (NoSuchMethodException e) { throw new NotFoundAddressException (String.format("cannot found address by id:%s" , id)); } }
(3). if/for 嵌套消除 通常我们会在代码逻辑的时候,碰到许多分支逻辑,代码可能会写的嵌套层次非常深,下面几个方式可以减少嵌套的层次
如果可以 return
,提前判断 False/True
进行提前返回
如果是在 for
循环中的 if
,使用 break/continue
进行中断
使用三目运算符消除 if/else
如果 else
条件多余,那么消除 else
,只剩下 if
使用 switch
表达式替代 if/else if
2. 第三方库隔离 第三方库的滥用,这就是多人开发中通常会碰到的问题。
对于个人开发的项目来说,我可以自由的选择第三方框架和库,控制权属于自己手上,这没问题,我清楚使用了什么库以及在哪里使用。
但是对于团队而言,还能以这种方式开发么,明显不能,每个人都有自己的编码风格,每个人都有自己擅长使用的库,假设每个人都在开发时都使用自己喜欢的第三方库辅助,那么整个工程将是混乱的。
假设是下面一个对从数据库取出的数据判空的情况,每个人可能有不同的写法,可能使用 Spring
的 CollectionUtils
,也有可能使用 apache
的 CollectionUtils
,每个库的 CollectionUtils
的 isEmpty
方法 (参数类型,参数个数) 可能不一样,这个时候,要是别人直接复制过来,刚好这里又已经引入了 CollectionUtils
的包,方法必然是会报错的。
1 2 3 4 5 6 7 8 9 10 import org.springframework.util.CollectionUtils;import org.apache.commons.collections4.CollectionUtils;@Test public void testFindAllAddresses () { List<Address> addresses = addressService.findAll(); if (CollectionUtils.isEmpty(addresses)) { return ; } }
这个时候就有必要进行第三方库的隔离,好处呢:
屏蔽第三方库的公共调用,由封装模块实现内部调用第三方库的细节
由于屏蔽了第三方库的公共调用,每个人调用封装库即可,即使替换第三方库也不影响开发。实现第三方库的平滑替换。
1 2 3 4 5 6 7 8 9 10 11 public class CollectionUtils { public static boolean isEmpty (@Nullable Collection<?> collection) { return org.springframework.util.CollectionUtils.isEmpty(collection); } public static boolean isNotEmpty (@Nullable Collection<?> collection) { return !CollectionUtils.isEmpty(collection); } }
如果是个人开发的项目,也需要做到第三方库的隔离么?对于开发规范以及软件的扩展性和伸缩性是有必要做的。
0x06 闲谈 人生第一次经历两次裁员,虽然这事不是发生在我身上,但是我觉得心情还是有点复杂,曾经是一起工作的同事,隔日就要开始为工作奔波。说到底,源头还是疫情的原因,公司面临经济危机才做出的下策,大家都能理解。
我还记得走之前,组长跟我说到,不要着眼于眼前的东西,多接触几个领域,关注关注 web3.0,区块链等等。
我现在想做的就是,做好基础建设,语言,算法,消息队列,分布式,技术前沿。
最近断断续续通关了两个游戏,一个是 FireWatch,一个是 Inside。
FireWatch 的色彩风格真是特别的好看,同时游戏本身属于半开放世界游戏,可以随时的停止对话,也就是说可以当一个走路模拟器。中期的时候夹杂着一些悬疑情节,一度让我以为这要变成恐怖游戏了,不过还好没有。
整个游戏带给我最大的感受是:孤独。作为一个看火人,整个山谷内除了和主角对话的 Deliah 以及失去儿子的 Ned 之外几乎没有任何动物和人。
主角因为妻子所患阿尔茨海默症无能为力之后选择到俄怀明州的山谷当一名看火人,以此来自我放逐和避世,期间和另一名看火人 Deliah 又互相吸引,Deliah 也是因为某种原因选择当看火人来逃避,两人经历七十九天从陌生到熟悉,我本以为最后会发生什么,结果确没有。
结尾的一场大火,使得两个人都要离开山谷,这场大火烧毁了两个以此来逃避的安乐园,也烧毁了他们对过去的留念,人总是要往前看的。经历过大火之后,森林也会重新长出萌芽,而他们也会有新的开始。
Inside,全程没有对话,解谜难度简单,剧情很不错,结局很震撼。