我有一个
String
值在运行时使用User
方法生成的toString
列表对象:
[User[firstName=John, lastName=Smith]]
有没有一种快速的方法来生成创建这样一个对象的 Java 源代码?理想情况下,它会生成以下源代码:
Arrays.asList(new User("John", "Smith")); // might also use getters-setters
这将使我更快地编写测试代码断言。
我的对象定义如下:
public record User (String firstName, String lastName) {}
出于好奇,我对此进行了尝试,结果证明它相当简单,如果你能忍受一些限制的话。其中一些是基本的,其他的只需要更多的代码就可以解决
toString
生成的并且是不宽容的(在错误的地方额外/缺少空格会破坏它)。toString
添加对
Map
的支持将相当简单。并且由于这些解析器的性质,修改它以实际构建对象而不是对象构造代码也将相当简单。这两个都留给读者作为练习。
完整的测试如下,但作为示例,这里是如何使用它:
import java.util.List;
public class Foo {
public static void main(String[] args) {
var parser = new RecordToStringParser(List.of(User.class, UserContainer.class));
System.out.println(parser.convert("User[firstName=John, lastName=Smith]"));
System.out.println(parser.convert("User[firstName=John, Doe, lastName=Smith]"));
System.out.println(parser.convert("UserContainer[user=User[firstName=John, lastName=Smith], flags=13]"));
System.out.println(parser.convert("UserContainer[user=null, flags=42]"));
System.out.println(parser.convert("[User[firstName=John, lastName=Smith], UserContainer[user=null, flags=1]]"));
System.out.println(parser.convert("UserContainer[user=null, flags=what are you talking about]"));
}
}
record User(String firstName, String lastName) {}
record UserContainer(User user, int flags) {}
这打印
new User("John", "Smith")
new User("John, Doe", "Smith")
new UserContainer(new User("John", "Smith"), 13)
new UserContainer(null, 42)
List.of(new User("John", "Smith"), new UserContainer(null, 1))
new UserContainer(null, what are you talking about)
注意最后一个是如何在不验证类型的情况下盲目转储值的? 🤷
import java.lang.reflect.RecordComponent;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
public class RecordToStringParser {
private static final Pattern RECORD_NAME = Pattern.compile("^[A-Z][A-Za-z0-9]+");
private static final Set<Class<?>> WRAPPER_CLASSES = Set.of(
Boolean.class, Byte.class, Character.class, Short.class, Integer.class, Long.class, Float.class, Double.class);
private final Map<String, Class<? extends Record>> classes;
public RecordToStringParser(Class<? extends Record> c1) {
this(List.of(c1));
}
public RecordToStringParser(List<Class<? extends Record>> inputClasses) {
if (inputClasses.isEmpty()) {
throw new IllegalArgumentException("No record classes specified!");
}
try {
classes = inputClasses.stream()
.collect(Collectors.toMap(Class::getSimpleName, Function.identity()));
} catch (IllegalStateException e) {
// this happens when simple names are not unique!
throw new IllegalArgumentException("Colliding class names detected!", e);
}
for (String name : classes.keySet()) {
if (!RECORD_NAME.matcher(name).matches()) {
throw new IllegalArgumentException("Nonconforming class name found: " + name);
}
}
}
public String convert(String input) {
return new Parse(input).parse();
}
private class Parse {
String in;
int pos = 0;
StringBuilder out = new StringBuilder();
Parse(String input) {
this.in = input;
}
String parse() {
parseNext();
if (pos != in.length()) {
throw err("Unexpected content after successful parse");
}
return out.toString();
}
private void parseNext() {
if (isNext('[')) {
parseList();
} else {
parseRecord();
}
}
private void parseRecord() {
if (maybeConsume("null")) {
out.append("null");
return;
}
Matcher matcher = RECORD_NAME.matcher(in);
matcher.region(pos, in.length());
if (!matcher.find()) {
throw err("Record expected.");
}
pos = matcher.end();
consume('[');
String className = matcher.group();
out.append("new ").append(className).append("(");
Class<? extends Record> recordClass = classes.get(className);
if (recordClass == null) {
throw err("Unknown record class " + className);
}
parseMembers(recordClass);
out.append(")");
consume(']');
}
private void parseRecord(Class<? extends Record> type) {
if (maybeConsume("null")) {
out.append("null");
return;
}
consume(type.getSimpleName());
consume('[');
out.append("new ").append(type.getSimpleName()).append("(");
parseMembers(type);
out.append(")");
consume(']');
}
private void parseMembers(Class<? extends Record> recordClass) {
RecordComponent[] components = recordClass.getRecordComponents();
for (int i = 0; i < components.length; i++) {
RecordComponent component = components[i];
boolean isLastComponent = i == components.length - 1;
consume(component.getName());
consume('=');
Class<?> type = component.getType();
if (type.isRecord()) {
parseRecord(type.asSubclass(Record.class));
} else if (List.class == type) {
parseList();
} else if (type == String.class || type.isPrimitive() || WRAPPER_CLASSES.contains(type)) {
parseSimpleValues(components, i, isLastComponent, type);
} else {
throw err("Unsupported component type " + type);
}
if (!isLastComponent) {
consume(", ");
out.append(", ");
}
}
}
private void parseSimpleValues(RecordComponent[] components, int i, boolean isLastComponent, Class<?> type) {
String expectedSuffix = isLastComponent ? "]" : ", " + components[i + 1].getName() + "=";
isNext('\0'); // ensure we throw the right exception when we're at the end.
int valueEnd = in.indexOf(expectedSuffix, pos);
if (valueEnd == -1) {
throw err("Failed to find end of value!");
}
String value = in.substring(pos, valueEnd);
if (type == String.class && !value.equals("null")) {
// interpret string values null as the value null and not the string "null"
out.append('"');
for (char c : value.toCharArray()) {
switch (c) {
case '"', '\\' -> out.append('\\').append(c);
case '\n' -> out.append("\\n");
case '\t' -> out.append("\\t");
// maybe add some more?
default -> out.append(c);
}
}
out.append('"');
} else {
// maybe add verification?
out.append(value);
}
pos = valueEnd;
}
private void parseList() {
if (maybeConsume("null")) {
out.append("null");
return;
}
consume('[');
out.append("List.of(");
if (!isNext(']')) {
parseNext();
while (maybeConsume(", ")) {
out.append(", ");
parseRecord();
}
}
consume(']');
out.append(")");
}
private boolean isNext(char expected) {
if (pos >= in.length()) {
throw err("Unexpected end of string.");
}
return in.charAt(pos) == expected;
}
private boolean maybeConsume(String expected) {
boolean matches = in.regionMatches(pos, expected, 0, expected.length());
if (matches) {
pos += expected.length();
}
return matches;
}
private void consume(char expected) {
if (pos >= in.length()) {
throw err("Unexpected end of string, expected " + expected);
}
if (in.charAt(pos) != expected) {
throw err("Expected " + expected);
}
pos++;
}
private void consume(String expected) {
if (!in.regionMatches(pos, expected, 0, expected.length())) {
throw err("Expected " + expected);
}
pos += expected.length();
}
private IllegalArgumentException err(String msg) {
return new IllegalArgumentException(String.format("Unexpected input at pos %d (%s): %s", pos, this, msg));
}
@Override
public String toString() {
// simple way to see parser state in the debugger ;-)
return in.substring(0, pos) + "<|>" + in.substring(pos);
}
}
}
测试班:
import static org.junit.jupiter.api.Assertions.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
public class RecordToStringParserTest {
private List<Class<? extends Record>> BASE_RECORD_CLASSES = List.of(
Empty.class, Simple.class, WithNumber.class, NestingSimple.class, NestingListOfSimple.class, Recursive.class,
TwoStrings.class, NestingRecord.class);
@Test
void mustProvideClasses() {
final List<Class<? extends Record>> noClasses = List.of();
assertThrows(IllegalArgumentException.class, () -> new RecordToStringParser(noClasses));
}
@Test
void classNamesMustStartWithUpperCase() {
assertThrows(IllegalArgumentException.class, () -> new RecordToStringParser(lowerRecordName.class));
}
@Test
void classNamesMustBeAlphaNumeric() {
assertThrows(IllegalArgumentException.class, () -> new RecordToStringParser(NonAlphaNum_.class));
}
@Test
void simpleClassNamesMustBeUnique() {
List<Class<? extends Record>> collidingClasses = List.of(NS1.Collision.class, NS2.Collision.class);
assertThrows(IllegalArgumentException.class,
() -> new RecordToStringParser(collidingClasses));
}
@SuppressWarnings("unchecked")
public static Object[][] realToStringSources() {
return new Object[][] {
{new Empty(), "new Empty()"},
{new Simple("bar"), "new Simple(\"bar\")"},
{new Simple(""), "new Simple(\"\")"},
{new Simple(null), "new Simple(null)"},
{new Simple("\n"), "new Simple(\"\\n\")"},
{new Simple("\\"), "new Simple(\"\\\\\")"},
{new Simple("\"\\\""), "new Simple(\"\\\"\\\\\\\"\")"}, // thanks, I hate it
{new WithNumber(1), "new WithNumber(1)"},
{new NestingSimple(new Simple("bar")), "new NestingSimple(new Simple(\"bar\"))"},
{new NestingSimple(null), "new NestingSimple(null)"},
{new Recursive(null), "new Recursive(null)"},
{new Recursive(new Recursive(null)), "new Recursive(new Recursive(null))"},
{List.of(), "List.of()"},
{List.of(new Empty()), "List.of(new Empty())"},
{List.of(new Empty(), new Empty()), "List.of(new Empty(), new Empty())"},
{List.of(List.of(new Empty())), "List.of(List.of(new Empty()))"},
{new NestingListOfSimple(List.of(new Simple("bar"))), "new NestingListOfSimple(List.of(new Simple(\"bar\")))"},
{new TwoStrings("foo", "bar"), "new TwoStrings(\"foo\", \"bar\")"},
{new TwoStrings("foo, bar", "bar"), "new TwoStrings(\"foo, bar\", \"bar\")"},
{new TwoStrings("John", "Smith"), "new TwoStrings(\"John\", \"Smith\")"},
// "WRONG" results start here
// List.of() can't contain null values, but we don't check for that, so we tolerate it
{new ArrayList<>(Arrays.asList(null, new Empty())), "List.of(null, new Empty())"},
// Simple[foo=null] is ambiguous, we decided to interpret it as null and not the string value "null"
{new Simple("null"), "new Simple(null)"},
// We don't check for insanity/generics breakage
{new NestingListOfSimple((List<Simple>) ((List<?>) List.of(new WithNumber(1)))),
"new NestingListOfSimple(List.of(new WithNumber(1)))"},
// Well, that's just ambiguous!
{new TwoStrings("foo, bar=", "bar"), "new TwoStrings(\"foo\", \", bar=bar\")"},
// We don't verify simple types, so it'll blindly output nonsense
{"WithNumber[foo=bar]", "new WithNumber(bar)"},
};
}
@ParameterizedTest
@MethodSource("realToStringSources")
void parseRealToString(Object record, String expectedOutput) {
String input = record.toString();
RecordToStringParser parser = new RecordToStringParser(BASE_RECORD_CLASSES);
String output = parser.convert(input);
assertEquals(expectedOutput, output);
}
public static String[] unparseableStrings() {
return new String[] {
"",
"]",
"[",
"Empty",
"Empty[",
"Simple[foo=",
"NestingSimple[foo=10]",
"UnknownClass[]",
// "WithNumber[foo=bar]", // this isn't validated
"[]]",
"[bar]", // non-record values in lists are not supported
"{}", // maps are not supported, but wouldn't be too hard
"NestingRecord[r=Empty[]]", // this could easily be supported, but why?
};
}
@ParameterizedTest
@MethodSource("unparseableStrings")
void failToParse(String input) {
RecordToStringParser parser = new RecordToStringParser(BASE_RECORD_CLASSES);
assertThrows(IllegalArgumentException.class, () -> parser.convert(input));
}
record lowerRecordName(String foo) {}
record NonAlphaNum_(String foo) {}
record Empty() {}
record Simple(String foo) {}
record NestingSimple(Simple foo) {}
record NestingListOfSimple(List<Simple> foo) {}
record Recursive(Recursive foo) {}
record WithNumber(int foo) {}
record TwoStrings(String foo, String bar) {}
record NestingRecord(Record r) {}
static class NS1 {
record Collision(String foo) {}
}
static class NS2 {
record Collision(String foo) {}
}
}
不,没有。
. . . . . . . . . . . . . . . . . . . . . . . . . . .