提交 c32f006f 编写于 作者: K Kohsuke Kawaguchi

Allow AbstractBuilds to be garbage collected (and reloaded lazy later)

上级 9e6d8f71
......@@ -63,6 +63,7 @@ import hudson.util.LogTaskListener;
import hudson.util.VariableResolver;
import jenkins.model.Jenkins;
import jenkins.model.lazy.AbstractLazyLoadRunMap.Direction;
import jenkins.model.lazy.BuildReference;
import org.kohsuke.stapler.Stapler;
import org.kohsuke.stapler.StaplerRequest;
import org.kohsuke.stapler.StaplerResponse;
......@@ -155,6 +156,20 @@ public abstract class AbstractBuild<P extends AbstractProject<P,R>,R extends Abs
*/
protected transient List<Environment> buildEnvironments;
/**
* Pointers to form bi-directional link between adjacent {@link AbstractBuild}s.
*
* <p>
* Unlike {@link Run}, {@link AbstractBuild}s do lazy-loading, so we don't use
* {@link Run#previousBuild} and {@link Run#nextBuild}, and instead use these
* fields and point to {@link #selfReference} of adjacent builds.
*/
private volatile transient BuildReference<R> previousBuild, nextBuild;
/*package*/ final transient BuildReference<R> selfReference = new BuildReference<R>(getId(),_this());
protected AbstractBuild(P job) throws IOException {
super(job);
}
......@@ -171,32 +186,78 @@ public abstract class AbstractBuild<P extends AbstractProject<P,R>,R extends Abs
return getParent();
}
private transient boolean previousBuildComputed, nextBuildComputed;
@Override
void dropLinks() {
super.dropLinks();
if(nextBuild!=null) {
AbstractBuild nb = nextBuild.get();
if (nb!=null) nb.previousBuild = previousBuild;
}
if(previousBuild!=null) {
AbstractBuild pb = previousBuild.get();
if (pb!=null) pb.nextBuild = nextBuild;
}
}
@Override
public R getPreviousBuild() {
if (previousBuild==null && !previousBuildComputed) {
// having two neighbors pointing to each other is important to make RunMap.removeValue work
R previousBuild = getParent().builds.search(number-1, Direction.DESC);
if (previousBuild!=null)
previousBuild.nextBuild = (R)this;
this.previousBuild = previousBuild;
previousBuildComputed = true;
while (true) {
BuildReference<R> r = previousBuild; // capture the value once
if (r==null) {
// having two neighbors pointing to each other is important to make RunMap.removeValue work
R pb = getParent().builds.search(number-1, Direction.DESC);
if (pb!=null) {
((AbstractBuild)pb).nextBuild = selfReference; // establish bi-di link
this.previousBuild = pb.selfReference;
return pb;
} else {
// this indicates that we know there's no previous build
// (as opposed to we don't know if/what our previous build is.
this.previousBuild = selfReference;
return null;
}
}
if (r==selfReference)
return null;
R referent = r.get();
if (referent!=null) return referent;
// the reference points to a GC-ed object, drop the reference and do it again
this.previousBuild = null;
}
return previousBuild;
}
@Override
public R getNextBuild() {
if (nextBuild==null && !nextBuildComputed) {
// having two neighbors pointing to each other is important to make RunMap.removeValue work
R nextBuild = getParent().builds.search(number+1, Direction.ASC);
if (nextBuild!=null)
nextBuild.previousBuild = (R)this;
this.nextBuild = nextBuild;
nextBuildComputed = true;
while (true) {
BuildReference<R> r = nextBuild; // capture the value once
if (r==null) {
// having two neighbors pointing to each other is important to make RunMap.removeValue work
R nb = getParent().builds.search(number+1, Direction.ASC);
if (nb!=null) {
((AbstractBuild)nb).previousBuild = selfReference; // establish bi-di link
this.nextBuild = nb.selfReference;
return nb;
} else {
// this indicates that we know there's no next build
// (as opposed to we don't know if/what our next build is.
this.nextBuild = selfReference;
return null;
}
}
if (r==selfReference)
return null;
R referent = r.get();
if (referent!=null) return referent;
// the reference points to a GC-ed object, drop the reference and do it again
this.nextBuild = null;
}
return nextBuild;
}
/**
......
/*
* The MIT License
*
* Copyright (c) 2004-2010, Sun Microsystems, Inc., Kohsuke Kawaguchi,
* Copyright (c) 2004-2012, Sun Microsystems, Inc., Kohsuke Kawaguchi,
* Daniel Dyer, Red Hat, Inc., Tom Huybrechts, Romain Seguy, Yahoo! Inc.,
* Darek Ostolski
* Darek Ostolski, CloudBees, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
......@@ -96,6 +96,7 @@ import javax.servlet.ServletException;
import javax.servlet.http.HttpServletResponse;
import jenkins.model.Jenkins;
import jenkins.model.lazy.BuildReference;
import jenkins.util.io.OnMaster;
import net.sf.json.JSONObject;
import org.apache.commons.io.input.NullInputStream;
......@@ -338,7 +339,7 @@ public abstract class Run <JobT extends Job<JobT,RunT>,RunT extends Run<JobT,Run
* Obtains 'this' in a more type safe signature.
*/
@SuppressWarnings({"unchecked"})
private RunT _this() {
protected RunT _this() {
return (RunT)this;
}
......@@ -690,6 +691,17 @@ public abstract class Run <JobT extends Job<JobT,RunT>,RunT extends Run<JobT,Run
return number;
}
/**
* Called by {@link RunMap} to drop bi-directional links in preparation for
* deleting a build.
*/
/*package*/ void dropLinks() {
if(nextBuild!=null)
nextBuild.previousBuild = previousBuild;
if(previousBuild!=null)
previousBuild.nextBuild = nextBuild;
}
public RunT getPreviousBuild() {
return previousBuild;
}
......
......@@ -24,6 +24,7 @@
package hudson.model;
import jenkins.model.lazy.AbstractLazyLoadRunMap;
import jenkins.model.lazy.BuildReference;
import org.apache.commons.collections.comparators.ReverseComparator;
import java.io.File;
......@@ -52,6 +53,8 @@ import static jenkins.model.lazy.AbstractLazyLoadRunMap.Direction.*;
*
* @author Kohsuke Kawaguchi
*/
// in practice R is always bound by AbstractBuild, but making that change causes all kinds of
// signature breakage.
public final class RunMap<R extends Run<?,R>> extends AbstractLazyLoadRunMap<R> implements Iterable<R> {
/**
* Read-only view of this map.
......@@ -114,11 +117,7 @@ public final class RunMap<R extends Run<?,R>> extends AbstractLazyLoadRunMap<R>
@Override
public boolean removeValue(R run) {
if(run.nextBuild!=null)
run.nextBuild.previousBuild = run.previousBuild;
if(run.previousBuild!=null)
run.previousBuild.nextBuild = run.nextBuild;
run.dropLinks();
return super.removeValue(run);
}
......@@ -170,6 +169,18 @@ public final class RunMap<R extends Run<?,R>> extends AbstractLazyLoadRunMap<R>
return r.getId();
}
/**
* Reuses the same reference as much as we can.
* <p>
* If concurrency ends up creating a few extra, that's OK, because
* we are really just trying to reduce the # of references we create.
*/
@Override
protected BuildReference<R> createReference(R r) {
if (r instanceof AbstractBuild) return ((AbstractBuild)r).selfReference;
else return super.createReference(r);
}
@Override
protected FilenameFilter createDirectoryFilter() {
final SimpleDateFormat formatter = Run.ID_FORMATTER.get();
......
......@@ -23,12 +23,14 @@
*/
package jenkins.model.lazy;
import hudson.model.Run;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;
import java.io.File;
import java.io.FilenameFilter;
import java.io.IOException;
import java.lang.ref.Reference;
import java.util.AbstractMap;
import java.util.ArrayList;
import java.util.Arrays;
......@@ -104,13 +106,13 @@ public abstract class AbstractLazyLoadRunMap<R> extends AbstractMap<Integer,R> i
* Stores the mapping from build number to build, for builds that are already loaded.
*/
// copy on write
private volatile TreeMap<Integer,R> byNumber = new TreeMap<Integer,R>(COMPARATOR);
private volatile TreeMap<Integer,BuildReference<R>> byNumber = new TreeMap<Integer,BuildReference<R>>(COMPARATOR);
/**
* Stores the build ID to build number for builds that we already know
*/
// copy on write
private volatile TreeMap<String,R> byId = new TreeMap<String,R>();
private volatile TreeMap<String,BuildReference<R>> byId = new TreeMap<String,BuildReference<R>>();
/**
* Build IDs found as directories, in the ascending order.
......@@ -181,14 +183,14 @@ public abstract class AbstractLazyLoadRunMap<R> extends AbstractMap<Integer,R> i
@Override
public Set<Entry<Integer, R>> entrySet() {
return Collections.unmodifiableSet(all().entrySet());
return Collections.unmodifiableSet(new BuildReferenceMapAdapter<R>(this,all()).entrySet());
}
/**
* Returns a read-only view of records that has already been loaded.
*/
public SortedMap<Integer,R> getLoadedBuilds() {
return Collections.unmodifiableSortedMap(byNumber);
return Collections.unmodifiableSortedMap(new BuildReferenceMapAdapter<R>(this,byNumber));
}
/**
......@@ -214,7 +216,7 @@ public abstract class AbstractLazyLoadRunMap<R> extends AbstractMap<Integer,R> i
assert i!=null;
}
return Collections.unmodifiableSortedMap(byNumber.subMap(fromKey, toKey));
return Collections.unmodifiableSortedMap(new BuildReferenceMapAdapter<R>(this,byNumber.subMap(fromKey, toKey)));
}
public SortedMap<Integer, R> headMap(Integer toKey) {
......@@ -273,8 +275,12 @@ public abstract class AbstractLazyLoadRunMap<R> extends AbstractMap<Integer,R> i
* If DESC, finds the closest #M that satisfies M<=N.
*/
public R search(final int n, final Direction d) {
Entry<Integer, R> c = byNumber.ceilingEntry(n);
if (c!=null && c.getKey()== n) return c.getValue(); // found the exact #n
Entry<Integer, BuildReference<R>> c = byNumber.ceilingEntry(n);
if (c!=null && c.getKey()== n) {
R r = c.getValue().get();
if (r!=null)
return r; // found the exact #n
}
// at this point we know that we don't have #n loaded yet
......@@ -332,11 +338,11 @@ public abstract class AbstractLazyLoadRunMap<R> extends AbstractMap<Integer,R> i
// first, narrow down the candidate IDs to try by using two known number-to-ID mapping
if (idOnDisk.isEmpty()) return null;
Entry<Integer, R> f = byNumber.floorEntry(n);
Entry<Integer, BuildReference<R>> f = byNumber.floorEntry(n);
// if bound is null, use a sentinel value
String cid = c==null ? "\u0000" : getIdOf(c.getValue());
String fid = f==null ? "\uFFFF" : getIdOf(f.getValue());
String cid = c==null ? "\u0000" : c.getValue().id;
String fid = f==null ? "\uFFFF" : f.getValue().id;
// We know that the build we are looking for exists in this range
// we will narrow this down via binary search
......@@ -390,8 +396,9 @@ public abstract class AbstractLazyLoadRunMap<R> extends AbstractMap<Integer,R> i
}
public R getById(String id) {
if (byId.containsKey(id))
return byId.get(id);
if (byId.containsKey(id)) {
return unwrap(byId.get(id));
}
return load(id,true);
}
......@@ -409,11 +416,12 @@ public abstract class AbstractLazyLoadRunMap<R> extends AbstractMap<Integer,R> i
int n = getNumberOf(r);
copy();
R old = byId.put(id,r);
byNumber.put(n,r);
BuildReference<R> ref = createReference(r);
BuildReference<R> old = byId.put(id,ref);
byNumber.put(n,ref);
/*
search relies on the fact that every objet added via
search relies on the fact that every object added via
put() method be available in the xyzOnDisk index, so I'm adding them here
however, this is awfully inefficient. I wonder if there's any better way to do this?
*/
......@@ -431,15 +439,21 @@ public abstract class AbstractLazyLoadRunMap<R> extends AbstractMap<Integer,R> i
numberOnDisk = a;
}
return old;
return unwrap(old);
}
private R unwrap(Reference<R> ref) {
return ref!=null ? ref.get() : null;
}
@Override
public synchronized void putAll(Map<? extends Integer,? extends R> rhs) {
copy();
for (R r : rhs.values()) {
byId.put(getIdOf(r),r);
byNumber.put(getNumberOf(r),r);
String id = getIdOf(r);
BuildReference<R> ref = createReference(r);
byId.put(id,ref);
byNumber.put(getNumberOf(r),ref);
}
}
......@@ -452,7 +466,7 @@ public abstract class AbstractLazyLoadRunMap<R> extends AbstractMap<Integer,R> i
* @return
* fully populated map.
*/
private TreeMap<Integer,R> all() {
private TreeMap<Integer,BuildReference<R>> all() {
if (!fullyLoaded) {
synchronized (this) {
if (!fullyLoaded) {
......@@ -472,8 +486,8 @@ public abstract class AbstractLazyLoadRunMap<R> extends AbstractMap<Integer,R> i
* Creates a duplicate for the COW data structure in preparation for mutation.
*/
private void copy() {
byId = new TreeMap<String, R>(byId);
byNumber = new TreeMap<Integer,R>(byNumber);
byId = new TreeMap<String, BuildReference<R>>(byId);
byNumber = new TreeMap<Integer,BuildReference<R>>(byNumber);
}
/**
......@@ -515,8 +529,11 @@ public abstract class AbstractLazyLoadRunMap<R> extends AbstractMap<Integer,R> i
if (r==null) return null;
if (copy) copy();
byId.put(getIdOf(r),r);
byNumber.put(getNumberOf(r),r);
String id = getIdOf(r);
BuildReference<R> ref = createReference(r);
byId.put(id,ref);
byNumber.put(getNumberOf(r),ref);
return r;
} catch (IOException e) {
LOGGER.log(Level.WARNING, "Failed to load "+dataDir,e);
......@@ -524,9 +541,23 @@ public abstract class AbstractLazyLoadRunMap<R> extends AbstractMap<Integer,R> i
return null;
}
/**
* Subtype to provide {@link Run#getNumber()} so that this class doesn't have to depend on it.
*/
protected abstract int getNumberOf(R r);
/**
* Subtype to provide {@link Run#getId()} so that this class doesn't have to depend on it.
*/
protected abstract String getIdOf(R r);
/**
* Allow subtype to capture a reference.
*/
protected BuildReference<R> createReference(R r) {
return new BuildReference<R>(getIdOf(r),r);
}
/**
* Parses {@code R} instance from data in the specified directory.
*
......@@ -541,19 +572,21 @@ public abstract class AbstractLazyLoadRunMap<R> extends AbstractMap<Integer,R> i
public synchronized boolean removeValue(R run) {
copy();
byNumber.remove(getNumberOf(run));
R old = byId.remove(getIdOf(run));
return old!=null;
BuildReference<R> old = byId.remove(getIdOf(run));
return unwrap(old)!=null;
}
/**
* Replaces all the current loaded Rs with the given ones.
*/
public synchronized void reset(TreeMap<Integer,R> builds) {
TreeMap<Integer, R> byNumber = new TreeMap<Integer,R>(COMPARATOR);
TreeMap<String, R> byId = new TreeMap<String, R>(COMPARATOR);
TreeMap<Integer, BuildReference<R>> byNumber = new TreeMap<Integer,BuildReference<R>>(COMPARATOR);
TreeMap<String, BuildReference<R>> byId = new TreeMap<String, BuildReference<R>>(COMPARATOR);
for (R r : builds.values()) {
byId.put(getIdOf(r),r);
byNumber.put(getNumberOf(r),r);
String id = getIdOf(r);
BuildReference<R> ref = createReference(r);
byId.put(id,ref);
byNumber.put(getNumberOf(r),ref);
}
this.byNumber = byNumber;
......
package jenkins.model.lazy;
import java.lang.ref.SoftReference;
/**
* {@link SoftReference} to a build object.
*
* <p>
* To be able to re-retrieve the referent in case it is lost, this class
* remembers its ID (the job name is provided by the context because a {@link BuildReference}
* belongs to one and only {@link AbstractLazyLoadRunMap}.)
*
* <p>
* We use this ID for equality/hashCode so that we can have a collection of {@link BuildReference}
* and find things in it.
*
* @author Kohsuke Kawaguchi
*/
public final class BuildReference<R> extends SoftReference<R> {
final String id;
public BuildReference(String id, R referent) {
super(referent);
this.id = id;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
BuildReference that = (BuildReference) o;
return id.equals(that.id);
}
@Override
public int hashCode() {
return id.hashCode();
}
}
package jenkins.model.lazy;
import groovy.util.MapEntry;
import hudson.util.AdaptedIterator;
import javax.annotation.Nullable;
import java.lang.reflect.Array;
import java.util.Collection;
import java.util.Comparator;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
import java.util.SortedMap;
/**
* Take {@code SortedMap<Integer,BuildReference<R>>} and make it look like {@code SortedMap<Integer,R>}.
*
* When {@link BuildReference} lost the build object, we'll use {@link AbstractLazyLoadRunMap#getById(String)}
* to obtain one.
*
* @author Kohsuke Kawaguchi
*/
class BuildReferenceMapAdapter<R> implements SortedMap<Integer,R> {
private final AbstractLazyLoadRunMap<R> loader;
private final SortedMap<Integer,BuildReference<R>> core;
BuildReferenceMapAdapter(AbstractLazyLoadRunMap<R> loader, SortedMap<Integer, BuildReference<R>> core) {
this.loader = loader;
this.core = core;
}
private R unwrap(@Nullable BuildReference<R> ref) {
if (ref==null) return null;
R v = ref.get();
if (v==null)
v = loader.getById(ref.id);
return v;
}
private BuildReference<R> wrap(@Nullable R value) {
if (value==null) return null;
return loader.createReference(value);
}
public Comparator<? super Integer> comparator() {
return core.comparator();
}
public SortedMap<Integer, R> subMap(Integer fromKey, Integer toKey) {
return new BuildReferenceMapAdapter<R>(loader,core.subMap(fromKey, toKey));
}
public SortedMap<Integer, R> headMap(Integer toKey) {
return new BuildReferenceMapAdapter<R>(loader,core.headMap(toKey));
}
public SortedMap<Integer, R> tailMap(Integer fromKey) {
return new BuildReferenceMapAdapter<R>(loader,core.tailMap(fromKey));
}
public Integer firstKey() {
return core.firstKey();
}
public Integer lastKey() {
return core.lastKey();
}
public Set<Integer> keySet() {
return core.keySet();
}
public Collection<R> values() {
return new CollectionAdapter(core.values());
}
public Set<Entry<Integer,R>> entrySet() {
return new SetAdapter(core.entrySet());
}
public int size() {
return core.size();
}
public boolean isEmpty() {
return core.isEmpty();
}
public boolean containsKey(Object key) {
return core.containsKey(key);
}
public boolean containsValue(Object value) {
return core.containsValue(value);
}
public R get(Object key) {
return unwrap(core.get(key));
}
public R put(Integer key, R value) {
return unwrap(core.put(key, wrap(value)));
}
public R remove(Object key) {
return unwrap(core.remove(key));
}
public void putAll(Map<? extends Integer, ? extends R> m) {
for (Entry<? extends Integer, ? extends R> e : m.entrySet())
put(e.getKey(), e.getValue());
}
public void clear() {
core.clear();
}
@Override
public boolean equals(Object o) {
return core.equals(o);
}
@Override
public int hashCode() {
return core.hashCode();
}
private class CollectionAdapter implements Collection<R> {
private final Collection<BuildReference<R>> core;
private CollectionAdapter(Collection<BuildReference<R>> core) {
this.core = core;
}
public int size() {
return core.size();
}
public boolean isEmpty() {
return core.isEmpty();
}
public boolean contains(Object o) {
// TODO: to properly pass this onto core, we need to wrap o into BuildReference but also needs to figure out ID.
throw new UnsupportedOperationException();
}
public Iterator<R> iterator() {
return new AdaptedIterator<BuildReference<R>,R>(core.iterator()) {
protected R adapt(BuildReference<R> ref) {
return unwrap(ref);
}
};
}
public Object[] toArray() {
return _unwrap(core.toArray());
}
public <T> T[] toArray(T[] a) {
int size = size();
T[] r = a;
if (r.length>size)
r = (T[]) Array.newInstance(a.getClass().getComponentType(), size);
Iterator<R> itr = iterator();
int i=0;
while (itr.hasNext()) {
r[i++] = (T)itr.next();
}
return r;
}
public boolean add(R value) {
return core.add(wrap(value));
}
public boolean remove(Object o) {
// return core.remove(o);
// TODO: to properly pass this onto core, we need to wrap o into BuildReference but also needs to figure out ID.
throw new UnsupportedOperationException();
}
public boolean containsAll(Collection<?> c) {
for (Object o : c) {
if (!contains(o))
return false;
}
return true;
}
public boolean addAll(Collection<? extends R> c) {
boolean b=false;
for (R r : c) {
b |= add(r);
}
return b;
}
public boolean removeAll(Collection<?> c) {
boolean b=false;
for (Object o : c) {
b|=remove(o);
}
return b;
}
public boolean retainAll(Collection<?> c) {
// TODO: to properly pass this onto core, we need to wrap o into BuildReference but also needs to figure out ID.
throw new UnsupportedOperationException();
}
public void clear() {
core.clear();
}
@Override
public boolean equals(Object o) {
return core.equals(o);
}
@Override
public int hashCode() {
return core.hashCode();
}
private Object[] _unwrap(Object[] r) {
for (int i=0; i<r.length; i++)
r[i] = unwrap((BuildReference<R>) r[i]);
return r;
}
}
private class SetAdapter implements Set<Entry<Integer, R>> {
private final Set<Entry<Integer, BuildReference<R>>> core;
private SetAdapter(Set<Entry<Integer, BuildReference<R>>> core) {
this.core = core;
}
public int size() {
return core.size();
}
public boolean isEmpty() {
return core.isEmpty();
}
public boolean contains(Object o) {
// TODO: to properly pass this onto core, we need to wrap o into BuildReference but also needs to figure out ID.
throw new UnsupportedOperationException();
}
public Iterator<Entry<Integer, R>> iterator() {
return new AdaptedIterator<Entry<Integer,BuildReference<R>>,Entry<Integer,R>>(core.iterator()) {
protected Entry<Integer, R> adapt(Entry<Integer, BuildReference<R>> e) {
return _unwrap(e);
}
};
}
public Object[] toArray() {
return _unwrap(core.toArray());
}
public <T> T[] toArray(T[] a) {
int size = size();
T[] r = a;
if (r.length>size)
r = (T[]) Array.newInstance(a.getClass().getComponentType(), size);
Iterator<Entry<Integer, R>> itr = iterator();
int i=0;
while (itr.hasNext()) {
r[i++] = (T)itr.next();
}
return r;
}
public boolean add(Entry<Integer, R> value) {
return core.add(_wrap(value));
}
public boolean remove(Object o) {
// return core.remove(o);
// TODO: to properly pass this onto core, we need to wrap o into BuildReference but also needs to figure out ID.
throw new UnsupportedOperationException();
}
public boolean containsAll(Collection<?> c) {
for (Object o : c) {
if (!contains(o))
return false;
}
return true;
}
public boolean addAll(Collection<? extends Entry<Integer,R>> c) {
boolean b=false;
for (Entry<Integer,R> r : c) {
b |= add(r);
}
return b;
}
public boolean removeAll(Collection<?> c) {
boolean b=false;
for (Object o : c) {
b|=remove(o);
}
return b;
}
public boolean retainAll(Collection<?> c) {
// TODO: to properly pass this onto core, we need to wrap o into BuildReference but also needs to figure out ID.
throw new UnsupportedOperationException();
}
public void clear() {
core.clear();
}
@Override
public boolean equals(Object o) {
return core.equals(o);
}
@Override
public int hashCode() {
return core.hashCode();
}
private Entry<Integer,BuildReference<R>> _wrap(Entry<Integer,R> e) {
return new MapEntry(e.getKey(),wrap(e.getValue()));
}
private Entry<Integer, R> _unwrap(Entry<Integer, BuildReference<R>> e) {
return new MapEntry(e.getKey(),unwrap(e.getValue()));
}
private Object[] _unwrap(Object[] r) {
for (int i=0; i<r.length; i++)
r[i] = _unwrap((Entry) r[i]);
return r;
}
}
}
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册